Live Quiz Platform
Product Requirements Document
| Status | Draft |
| Scope | Project (cross-cutting) |
| Last updated | 2026-06-05 |
| Source of truth | .claude/context/knowledgebase/ |
Contents
Overview
Overview
Problem statement
Live quizzes are a popular social format, and software platforms aimed at running them already exist. They tend to fall into one of two camps: feature-light tools that miss the TV-game-show production values quizmasters want, or feature-heavy platforms whose complexity makes set-up and operation a chore. We bring the rich, animated, interactive experience without the complexity, packaged as a subscription service quizmasters can rely on for a polished quiz night that still works on the unreliable internet typical of a real venue.
Solution outline
Four connected apps, one shared schema contract — JSON Schema in schemas/ codegen'd to C# for the Unity apps and TypeScript for the Angular Designer:
- Designer (Windows, macOS — iPad / Android tablet authoring is Stretch) — author quizzes as ordered slides grouped into rounds, with each slide carrying a Host canvas, a Client canvas, and per-slide host-notes; save
.quizfiles to disk and push them to a Host over the local network. - Host (iPad, Windows, macOS, Android tablet) — receive a
.quizfrom the Designer (or load one from disk), run the live session as a local-network server, render each slide's Host canvas on a connected projector or TV. On Client join, distributes the.quizpackage to the clients so per-slide play has no network round-trips. - Client (iPhone, Android phone, iPad, Android tablet) — each team joins over local Wi-Fi from one shared device and sees each slide's Client canvas, with elements collecting the team's answers and running mini-games as the slide demands.
- Remote (iPhone, Android phone, iPad, Android tablet) — the quizmaster's pocket controller. Pairs with the Host, mirrors the Host display, shows the author's host-notes for the current slide, and sends control messages so the quizmaster can walk the room. MVP ships minimum viable controls (advance / go-back); the rich control commands (trigger reveals, lock/unlock input, extend/skip timer, override scoring, jump-to-slide) land in Alpha.
A slide's elements are instances of object types — pluggable modules that contribute schema, editor surface, runtime behaviour, and protocol extensions. v1 ships a built-in catalogue (text, image, audio, video, multiple-choice/free-text/drawing/buzzer inputs, timer, leaderboard, mini-game) and adding a new built-in object type does not require changing core code. v1 is fully local: no cloud, no account, no internet at quiz time. Cloud-backed authoring (account, library, Designer↔cloud↔Host distribution, bundle-supplied object types) is a stretch goal for a future release. See Architecture for the full picture and Applications for per-app responsibilities.
Goals
- Deliver a polished, reliable live quiz experience across the supported tablet, phone, and desktop platforms.
- Support rich, interactive slides via a pluggable object-type model — text and media for the question, varied input modalities for the answer, animated reveals and mini-games where the round demands.
- Allow quiz authors to plan, edit, save, and transfer quizzes between their own devices over a local network. (A cloud-backed account/library system is a future stretch goal.)
- Operate entirely over local Wi-Fi at the venue, with no requirement for internet at any point in v1.
- Keep the system architecture future-proof for additions like 3D content and internet-based play.
v1 ships in four phases — MVP, Alpha, Beta, Production. Beyond v1 is a Stretch backlog. Scope and acceptance criteria for each phase are in Phases.
Users
| Role | Device | Authenticated? | Primary actions |
|---|---|---|---|
| Quiz Author | Designer (Windows, macOS) + Host (Windows, macOS, iPad, Android tablet) | No in v1; stretch Yes (cloud account) for cloud-backed authoring | Create, edit, save quizzes locally; transfer to host over LAN; run quiz nights. |
| Quizmaster (live operator) | Host + optional Remote on phone/tablet | No (paired with Host on the LAN) | Run the live session. The Remote app on the quizmaster's phone mirrors the Host display, shows host-notes, and controls the Host (MVP: advance/go-back; Alpha: rich command set). |
| Team (Participants / Quiz Goers) | Client (iPhone, Android phone, iPad, Android tablet) | No (team name only) | One shared device per team. Join host, submit team answers, see scores. |
The Author and Host operator are typically the same person. Each team plays from a single shared Client device; v1 has no per-individual representation within a team. In v1 no app authenticates against anything — Designer→Host transfer is gated by being on the same local network. stretch When cloud-backed authoring is added, both Designer and Host authenticate against the Author's cloud account; the Client remains unauthenticated by design.
Applications
The platform consists of four apps. This page covers each app's responsibilities and supported platforms; for shared schemas, protocols, and the system-wide architecture see Architecture. For each app's numbered behaviors see Functional Requirements. For each app's UI entry-point inventory (menus / dialogs / panels / shortcuts) see UI Surfaces. For the on-disk shape (scenes, prefabs, scripts) inside each Unity project see Per-App Scaffolding; for the Designer workspace shape see Repository Layout — Designer project graph. Phase scoping is in Phases.
| App | Phase introduced | Primary platforms |
|---|---|---|
| Designer | MVP | Windows, macOS — single Tauri 2 + Angular codebase. iPad / Android tablet authoring is Stretch. |
| Host | MVP | iPad, Windows, macOS, Android tablet |
| Client | MVP | iPhone, Android phone, iPad, Android tablet |
| Remote | MVP (minimum viable controls); rich control commands in Alpha | iPhone, Android phone, iPad, Android tablet |
Designer scope in v1: Windows + macOS desktop only, from a single Tauri 2 + Angular codebase. iPad and Android tablet authoring are Stretch and ship derived from the web-based Designer codebase. Browser-based authoring is itself Stretch — v1 ships the Tauri desktop shell only.
ng serve is used during development as the Playwright-driveable target (Playwright cannot drive WebView2 / WKWebView directly), but the served bundle is not a shipping product — it is dev infrastructure for visual verification.
Designer designer
A content-authoring tool — a Tauri 2 desktop app hosting an Angular 21 application, shipping on Windows and macOS. Angular standalone components built with Angular Material + Angular CDK form the entire authoring UI — shell, panels, properties inspector, dialogs. Multi-window is real OS-level windowing via Tauri's WebviewWindow API, with a main-window-owns-state pattern (Tauri events broadcast AuthoringSession changes to secondary windows). Slide preview is a dedicated Quiz.Preview Unity project built only for WebGL and embedded inline as a <canvas> inside the Designer's main WebView — same WebGL build serves desktop and (Stretch) the future web tool, ~95-98% pixel fidelity vs native Host (accepted trade-off). iPad / Android tablet authoring is Stretch and ships from the web-based Designer codebase. Architecture and diagram in Designer Shell. Chrome uses the Studio operator-chrome theme — surface tokens, brand-hue ration, and per-component spec live in Design Specification — Studio.
The authoring model is PowerPoint-style slides. A quiz is an ordered list of slides, optionally grouped into rounds. Each slide owns two independent canvases:
- Host canvas — TV/projector-format, fixed virtual 1920×1080, where elements are placed at absolute coordinates.
- Client canvas — phone/tablet-format, responsive, where elements declare anchors / regions / a stack so the layout adapts across phone, tablet, portrait, and landscape.
Plus a per-slide host-notes field (free-form text the author writes for the quizmaster) which renders only on the Remote app during play, never on the Host canvas or any Client.
Authors place elements on each canvas — a text block, an image, a multiple-choice input, a timer, a drawing input, etc. — by dragging from a palette of object types (the v1 built-in catalogue; bundle-supplied types are a stretch goal). Each element is configured through that object type's Angular editor component in the properties inspector; the embedded Quiz.Preview Unity WebGL canvas shows the live pixel-accurate render of the same state.
Authors attach media (images, audio, video) as resources referenced by element properties, and configure timing and scoring per slide and per round. Authors also bundle team-customisation assets — buzzer jingles (F-DE-28) and premade avatars (F-DE-30) — which teams pick from at join. The Designer ships a small built-in default asset library (pre-licensed jingles + starter avatars) the author can pull from; selections are copied into the .quiz so the package stays self-contained. Stylus input for sketching and annotation (Apple Pencil and equivalents) is a post-v1 stretch goal — v1 is touch-only.
The MVP-target platforms (Windows, macOS) are stood up cross-platform from day one with the single Tauri 2 + Angular codebase. iPad and Android tablet authoring are Stretch.
For v1 the Designer saves quizzes as .quiz files on the local file system (via the Tauri filesystem plugin + zip crate). A "Push to Host" UI action discovers Hosts on the local Wi-Fi (via the Tauri mdns-sd Rust crate), lets the author pick one, and pushes the on-disk .quiz to it over the WebSocket. A separate Run from slide action (F-DE-27) launches a local Host process on the same machine — Tauri's process plugin spawns the Quiz.Host binary with --quiz <path> --start-at-slide <index> — starting at the currently-selected slide. PowerPoint convention: F5 runs from the first slide, Shift+F5 runs from the current slide. Cloud-backed authoring — accounts, a quiz library, version history, soft-delete, restore — is a stretch goal for a future release; v1 ships none of it. Authors can still organize their on-disk quizzes with titles, descriptions, and tags inside the package metadata.
Host host
The "TV show" app, runnable on iPad, Windows, macOS, and Android tablets, and typically connected to a projector or TV (HDMI on most platforms; AirPlay or USB-C also available on Apple devices). The Host:
- Loads
.quizfiles from the local file system. For v1 the user transfers them either by file copy or by accepting an incoming push from the Designer over the local network. Cloud-backed download (sign in, browse a library, download) is a stretch goal. - Validates each loaded package against its built-in object-type registry; refuses packages whose declared object types are missing or version-incompatible.
- Runs an in-process WebSocket server that handles three flows: receiving incoming
.quizpackage transfers from the Designer when idle, running live quiz sessions with Clients, and accepting control connections from the Remote app. - Advertises itself on the local network via Bonjour/mDNS so Designers, Clients, and Remotes can discover it.
- When a Client joins a session, eagerly pushes the slide content and Client-side resources the Client will need for the whole quiz, so per-slide play is round-trip-free.
- Advances through the quiz's slides, rendering each slide's Host canvas at the connected display's resolution (the 1920×1080 virtual canvas scales to fit) and dispatching element behaviours — playing media, running animations, driving "big reveals," showing leaderboards. Per-team identity rendering — team photo on leaderboard rows + team callouts (F-HO-26), and per-team buzzer jingle playback on
core.buzzer-inputfirst-press wins (F-HO-27) — lands in Alpha alongside the relevant object types. - Runs in dual-window operator-view mode (F-HO-25) when more than one display is connected: the audience window owns the projector / TV (clean slide canvas, ships the full Showtime palette per the cross-cutting Design Specification), and a second operator window sits on the operator's laptop / iPad screen with a mirror of the audience output, the current slide's host-notes, the session HUD (timer, scores, slide index), and the same nav + control affordances a paired Remote exposes — effectively an on-machine Remote. The operator window's chrome uses the calmer Studio operator-chrome theme — see Design Specification — Studio for tokens, brand-hue ration, and component rules. On a single-display machine the operator surfaces collapse to a summonable overlay.
- Persists session state (current slide pointer, team list, scores, slide-element state where relevant) to disk often enough that a Host crash followed by a relaunch can resume the same session — this is an Alpha-phase v1 requirement, not Stretch.
Client client
A lightweight team app for phones and tablets — iPhone, Android phone, iPad, and Android tablet. One shared device per team. The Client:
- Discovers active hosts on the local Wi-Fi network via Bonjour/mDNS.
- Lets a team enter a team name and join a session. From Alpha, the captain can also take a photo (or pick from the quiz's bundled premade-avatar set) and pick a per-team buzzer jingle (F-CL-14, F-CL-15); both flow to the Host in the join message and drive per-team rendering / audio there.
- Renders the current slide's Client canvas with its responsive layout adapting to the device's form factor, and dispatches per-element behaviours — collecting team input (multiple choice, free text, drawing, gesture), playing inline media, running mini-games where the slide includes one.
- Shows the team's score and standings.
- Persists its team identity locally so it can rejoin a recovered session as the same team without re-entering the team name (introduced alongside crash recovery in Alpha).
Remote remote
Phase: MVP (minimum viable controls); the rich control-command set lands in Alpha.
The quizmaster's pocket controller — a phone (or tablet) the quizmaster carries while walking the room. It runs on iPhone, Android phone, iPad, and Android tablet. The same person who runs the Host operates the Remote; the Remote does not replace the Host's keyboard/click controls but supplements them.
The Remote in MVP:
- Discovers active Hosts on the local Wi-Fi network via Bonjour/mDNS.
- Pairs with one Host at a time. Pairing is gated (manual confirm on the Host the first time a given Remote connects, or QR-code pairing — final UX is a build-plan decision).
- Connects to the paired Host over a WebSocket on a control message family distinct from Designer transfer and Client live-play.
- Renders a live preview of what the Host's main display is showing (a scaled-down mirror of the Host canvas).
- Displays the per-slide host-notes the author wrote in the Designer — hints, answer keys, presentation cues — visible only on the Remote.
- Displays live session state — current scores, timer remaining, slide index.
- Sends core navigation commands to the Host: advance and go-back.
- Continues to function if a Client misbehaves; treats Wi-Fi blips like the Client does (reconnect with the same Remote identity).
Remote chrome uses the Studio operator-chrome theme — controls, host-notes pane, status bar all sit on calm surfaces. The audience-mirror region inside renders Showtime at full saturation. See Design Specification — Studio for tokens and the per-app component spec.
In Alpha the Remote gains the rich control-command set: jump to a specific slide, trigger element reveals (show the leaderboard / show the answer), lock or unlock Client input, extend or skip the timer, override scoring per team. This is F-RE-9 / F-HO-24.
A session can run with zero or one Remote. There is no multi-Remote support in v1 — the Host owns the session, and one paired Remote at a time is enough to walk the room.
Architecture
Architecture Overview
The platform pairs three Unity live-play apps (Host, Client, Remote) with a Tauri 2 + Angular 21 Designer for content authoring, plus a fourth Quiz.Preview Unity project that serves only as the Designer's slide-render target (WebGL build, embedded inline as a <canvas> in the Designer's WebView). All four apps ship in MVP. The Remote ships in MVP with a minimum viable controls feature set (discovery, pairing, mirror, host-notes, advance/go-back); its rich control command set lands in Alpha. The Designer's authoring UI is built entirely from Angular standalone components running on Windows + macOS (Tauri desktop shell); a browser-hosted Web Designer is a future Stretch goal. See Designer Shell for the architecture and Decisions for the rationale. The Host, Client, and Remote are UGUI-driven for creative freedom (custom shaders, prefab compositions, animations).
For v1 the platform is fully local: the Designer exports .quiz files to disk and sends them to the Host over local Wi-Fi via a UI action; the Host receives, stores them locally, and at session time pushes per-Client content to each Client as they join. A cloud-backed authoring service (account, library, Designer→cloud→Host distribution) is a stretch goal for a future release — see Backend Schema.
Tauri 2 + Angular 21
authoring app — Win + macOS] HOST[Host
Unity · TV / projector app] REM[Remote
Unity · quizmaster controller] CLI[Clients × N
Unity · one device per team] DES -->|.quiz over WebSocket
Bonjour / mDNS discovery| HOST HOST -->|live-play WebSocket
eager push on join| CLI HOST <-->|control WebSocket| REM end subgraph stretch["Stretch — Cloud-backed"] CLOUD[Cloud backend
Auth · Quiz library — Postgres
Storage — .quiz packages
vendor TBD when promoted] end DES -.->|future: publish / sync| CLOUD HOST -.->|future: download library| CLOUD classDef primary fill:#FFFFFF,stroke:#FF009F,stroke-width:2px,color:#1F1933,rx:10,ry:10 classDef secondary fill:#F0EBE3,stroke:#16B2EB,stroke-width:2px,color:#1F1933,rx:10,ry:10 classDef accent fill:#F0EBE3,stroke:#961EEF,stroke-width:2px,color:#1F1933,rx:10,ry:10 class DES primary class HOST,REM,CLI secondary class CLOUD accent
Designer ships on Windows + macOS in MVP from one Tauri 2 + Angular codebase, desktop only. Web authoring (browser-hosted) is Stretch; iPad and Android tablet authoring is a further Stretch derived from it. The Host runs on iPad, Windows, macOS, and Android tablets. Clients run on iPhone, Android phones, iPad, and Android tablets. The Remote runs on the same Client-class platforms. Per-app responsibilities are in Applications.
In v1 there is no cloud and no authentication on any app. Designer and Host meet over the local network; Clients never reach beyond the local network either. The cloud-backed authoring path on the right is a future stretch goal, sketched here so the v1 architecture stays compatible with it.
Where to go next
- Tech Stack for engine, library, and tooling choices.
- Networking for the local-Wi-Fi transport and the live-play model.
- Object-Type Architecture for the slide / element / plugin model.
- Decisions for the rationale behind each load-bearing choice.
Status: Draft Last updated: 2026-04-27
Tech Stack
| Component | Choice | Rationale |
|---|---|---|
| Host, Client, Remote | Unity (C#) | Single engine across the three live-play apps; native 2D and 3D from day one; broad platform reach including iPad / Android tablet; mature animation, particle, and shader tooling. All three apps ship in MVP; the Remote ships with minimum viable controls in MVP and gains its rich control command set in Alpha. |
| Designer shell | Tauri 2 (Rust shell + system WebView) | Tiny native cross-platform shell — ~30 MB ship size — with first-class native-titlebar customisation (Win 11 Mica, macOS traffic-lights inset), real OS-level multi-window, native menus, native tray, native file dialogs. Spawns the Host as a subprocess for the Run from slide action (F-DE-27). See Designer Shell and Decisions. |
| Designer UI | Angular 21+ (TypeScript) with Angular Material + Angular CDK | Opinionated TypeScript framework with the strongest tool-grade UI primitives in the ecosystem — drag/drop, virtual scroll, overlay, tree, dialog. The component layer is shell-agnostic; the eventual Web Designer Stretch will reuse it unchanged behind a BrowserPlatformAdapter. |
| Designer multi-window | Tauri WebviewWindow API |
Real OS-level windows on Windows + macOS, each its own decorated WebView. Same Angular code, separate window instances; main-window-owns-state pattern with Tauri event broadcast to secondary windows. |
| Designer slide preview | Unity WebGL build embedded as a <canvas> inside the Designer's main WebView |
One preview integration that works inside any WebView. ~95-98% pixel fidelity vs native Host (WebGL ≠ D3D11 / Metal / Vulkan exactly — accepted trade-off). No HWND / NSView reparenting, no Unity-as-a-Library. Angular components push slide state via unityInstance.SendMessage; the WebGL build emits ready / rendered / error events back via [DllImport("__Internal")] JS callbacks consumed by Angular services. The same WebGL bundle slots into the future Web Designer without rework. |
| Designer ↔ Preview transport | In-WebView JS bridge (unityInstance.SendMessage + JS callbacks) |
The preview runs inside the Designer's main WebView; no inter-process IPC. |
| Slide preview Unity project | Quiz.Preview (WebGL-only build target) |
Dedicated Unity project, sibling to Quiz.Host / Quiz.Client / Quiz.Remote. Renders a single .quiz slide from pushed JSON state. No editing UI — pure render target driven by the Angular shell. Shares scenes, prefabs, and rendering setup with Host / Client via com.quiz.shared-assets (and com.quiz.runtime where Unity-aware). |
| Run from slide subprocess launch | Tauri sidecar Command::spawn |
Designer spawns the platform Quiz.Host binary with --quiz <path> --start-at-slide <index> --launched-from-designer. PowerPoint-style F5 / Shift+F5 keybindings. Local-machine-only path; LAN push stays under File → Push to Host. See Designer Shell — Run from slide. |
| Live-play UI (Host / Client / Remote) | UGUI (Unity's prefab/canvas UI system) | Creative freedom for live-play visuals — element prefabs, custom shaders, particle effects, animation, "big reveals." The Remote uses UGUI for its mirrored Host-canvas preview. |
| Shared logic (Unity apps) | C# class library (.NET Standard 2.1) consumed by Host / Client / Remote / Quiz.Preview via local UPM packages (see Repository Layout) | Quiz schemas, DTOs, scoring rules, and message contracts shared between the four Unity apps. UnityEngine-free, so it also runs in headless test harnesses. |
| Designer ↔ Unity-apps contract | Schema-first — JSON Schema source of truth in schemas/ |
Codegen produces C# types for the Unity apps and TypeScript types + validators for the Angular Designer. Designer business logic is implemented natively in TypeScript inside Angular services; no shared runtime library is consumed by both sides. Wire compatibility is enforced by both sides building against the same generated contract. See Separation of Concerns. |
| Designer business logic | TypeScript in Angular services | Quiz model manipulation, undo / redo, validation (against generated TS validators), authoring orchestration, push-to-Host orchestration. Stays portable to the eventual Web Designer Stretch behind the PlatformAdapter interface. |
| Designer file I/O + native integration | Tauri Rust plugins | Native file dialogs (tauri-plugin-dialog), filesystem read/write of .quiz archives (tauri-plugin-fs + zip crate), mDNS discovery (mdns-sd), native menus / tray, subprocess spawn. Stay thin; logic lives in TypeScript. |
| Embedded WebSocket server + client (Unity apps) | SimpleWebTransport (James-Frowen, MIT, active 2026) |
Single library for both server (Host) and client (Client / Remote) inside Unity. Bypasses Unity's broken HttpListener.AcceptWebSocketAsync with its own pure-TCP WebSocket impl. Used by Mirror Networking in production. |
| WebSocket client (Designer) | Browser WebSocket API (via rxjs/webSocket) |
The Designer runs inside a system WebView (Tauri). Standard WebSocket API — no third-party library needed. |
| Live-play and control protocol | WebSocket with typed message envelopes (validated against shared JSON Schemas) | Lightweight, well-supported on all target platforms; bidirectional; works over plain Wi-Fi without infrastructure. Distinct message families for Designer transfer, Client live-play, and Remote control share the same transport. See Networking. |
| Session state persistence (Host) | Local file (JSON, periodic snapshot) on the Host's local app data directory | Crash recovery is an Alpha-phase v1 requirement. Snapshot after every scoring event and every slide advance — small writes, fsync. Format is the same DTOs as the live message envelopes, so deserialise == replay. |
| Service discovery (Unity apps) | Makaretu.Dns.Multicast.New (active fork, MIT) |
C# library for both Host advertisement and Client / Remote discovery. Pure C#; native NSNetService (iOS) / NsdManager (Android) bridges held as fallback if Alpha prototype reveals reliability issues. |
| Service discovery (Designer) | mdns-sd Rust crate via a Tauri command |
Tauri Rust side handles mDNS browse + advertisement; the Angular side calls Tauri commands and consumes the results. The eventual Web Designer Stretch has no mDNS — author pairs to a Host by manual IP entry or QR-code pairing from the Host. |
| Camera capture (Client) | Unity WebCamTexture |
Single cross-platform Unity API for team-photo capture at join (F-CL-14). Covers our minimum: capture frame → centre-crop → bilinear downscale to 256 × 256 → JPEG-encode (q75, 96 KB cap). Native pickers + camera-roll access deferred to Beta if UX feedback demands. |
| Authentication & storage | None for v1. stretch Cloud account with authentication and storage; vendor TBD when stretch is promoted. | v1 is fully local; no auth, no cloud. The stretch-goal cloud backend gives multi-tenant authoring with per-owner isolation. See Backend Schema and Authentication. |
| 2D animation/effects | Unity native — UGUI, Animator, particle system, Shader Graph | First-class tooling for snappy animations, custom shaders, and "big reveal" moments. |
| Code-driven tweens (Unity apps) | DOTween | Sequences, easings, and chained tweens for UGUI elements. Mature and widely used in Unity. |
| Designer animations | Angular Animations + Motion One | Component-level animation API + lightweight imperative tween library. GPU-composited CSS keeps the authoring surface at 60 fps. |
| 2D mini-games | Unity native | Same engine as the rest of the app; no embed required. |
| 3D content | Unity native | First-class from day one. Whether any v1 object type actually uses 3D is a content decision, not a platform constraint. |
Rationale for each load-bearing choice is in Decisions.
Designer Shell
The Designer's authoring UI is a single Angular application hosted inside a Tauri 2 desktop shell for Windows + macOS — the v1 product. During development the Angular workspace also runs under ng serve in plain Chromium as the Playwright-drivable visual-verification target (Playwright cannot drive WebView2 / WKWebView directly); the served bundle is dev infrastructure, not a shipping product. Slide preview is a dedicated Quiz.Preview Unity project built only for WebGL — embedded inline as a <canvas> in the Tauri WebView.
The project layout (Quiz.Designer/ Tauri + Angular workspace, schemas/ for the cross-language contract, Quiz.Preview/ Unity project) lives in Repository Layout — Designer project graph. This page covers the architecture; that page covers the on-disk shape.
iPad / Android tablet authoring is Stretch. Browser-hosted authoring is itself Stretch; the PlatformAdapter interface introduced below keeps the eventual port additive rather than a rewrite.
For the rationale and the alternatives that were rejected, see Decisions. For the live stack table see Tech Stack. For the full inventory of authoring-shell entry-points (menus, dialogs, panels, shortcuts, context menus) and where each leads, see Designer surfaces.
Diagram
Rust · native window · custom titlebar · multi-window via WebviewWindow] TPLUG[Tauri plugins
dialog · fs · mdns-sd · zip · process spawn · menu · tray] WV[System WebView
WebView2 on Windows · WKWebView on macOS
hosts the Angular app + the embedded Quiz.Preview canvas] end subgraph t2["Tier 2 · Angular 21 app — TypeScript · same code in Tauri shell and future Web Designer"] direction TB ANG[Angular shell + components
Material + CDK · panels · palette · properties inspector · dialogs · titlebar] SVC[Angular services
AuthoringSession · CommandDispatcher · PersistenceService · LibraryService · TransferService · PreviewBridge] JSB[JS bridge
in-WebView · SendMessage · JS callbacks] PREV[Quiz.Preview <canvas>
Unity WebGL build embedded inline] end subgraph t3["Tier 3 · Contracts + Unity foundation"] direction LR SCH[schemas/
JSON Schema source of truth
codegen TS for Angular · codegen C# for com.quiz.core] CORE[com.quiz.core
.NET Standard 2.1 · schema · validation · scoring · .quiz read/write
consumed by Host / Client / Remote / Quiz.Preview] ASSETS[com.quiz.shared-assets
UPM · scenes · prefabs · materials · shaders · fonts] RT[com.quiz.runtime
UPM · Unity adapters] end USER --> TSHELL TSHELL -->|hosts| WV TSHELL -->|wires| TPLUG WV -->|renders| ANG ANG <-->|DI| SVC SVC -->|invoke commands| TPLUG ANG -->|push state| JSB JSB <-.->|in-process · SendMessage + render events| PREV SVC -.->|TS types from| SCH SCH -.->|C# types into| CORE PREV --> ASSETS PREV --> RT classDef primary fill:#FFFFFF,stroke:#FF009F,stroke-width:2px,color:#1F1933,rx:10,ry:10 classDef secondary fill:#F0EBE3,stroke:#16B2EB,stroke-width:2px,color:#1F1933,rx:10,ry:10 classDef accent fill:#F0EBE3,stroke:#961EEF,stroke-width:2px,color:#1F1933,rx:10,ry:10 classDef external fill:#1F1933,stroke:#1F1933,stroke-width:1px,color:#F8F5F0,rx:10,ry:10 class TSHELL,TPLUG,WV primary class ANG,SVC,JSB,PREV secondary class SCH,CORE,ASSETS,RT accent class USER external
Tier 1 — Tauri 2 shell
The native shell that the desktop Designer ships as. Targets Windows 10/11 and macOS 12+. Built once per OS via tauri build.
Subsystems:
- Tauri shell — Rust application that owns the OS window, the custom-drawn titlebar configuration (
decorations: false,titleBarStyle: "Overlay"on macOS,windowEffects: [mica]on Windows 11), the multi-window API (WebviewWindow), and the IPC bridge that exposes Tauri commands to JavaScript. - Tauri plugins — thin Rust-side capabilities the Angular app calls through Tauri commands:
tauri-plugin-dialog— native file open / save dialogs.tauri-plugin-fs+ thezipcrate — read / write.quizarchives.mdns-sd— Bonjour browse + advertise for the Push to Host action.tauri-plugin-process— spawnQuiz.Hostas a subprocess for Run from slide.tauri-plugin-menu— native OS menus (File / Edit / View / Help).tauri-plugin-tray— optional system tray entry.- System WebView — WebView2 on Windows, WKWebView on macOS. Hosts the Angular application and the embedded
Quiz.PreviewUnity WebGL canvas. No Chromium is bundled — the shell uses each OS's existing WebView runtime.
Tier 2 — Angular 21 application
The authoring UI itself, written in TypeScript. Shell-specific capabilities (mDNS, OS dialogs, subprocess spawn) are reached through an abstraction layer: PlatformAdapter provides one interface; TauriPlatformAdapter wires it to Tauri commands. v1 ships only the Tauri impl; the abstraction exists so the eventual Web Designer port lands a BrowserPlatformAdapter against browser APIs without rewriting components or services.
Subsystems:
- Angular shell + components — every authoring surface (main shell, slide list, palette, properties inspector, library panel, push-to-Host dialog, settings, titlebar) is an Angular standalone component. Angular Material provides forms / dialogs / inputs; Angular CDK provides drag-drop / overlay / virtual scroll / tree primitives.
- Angular services (DI) — the business-logic layer in TypeScript:
AuthoringSession—activeQuiz,selection,isDirty, exposed as RxJS signals / observables that components subscribe to.CommandDispatcher—execute()+undo()pipeline; every quiz mutation goes through a command for undoability.PersistenceService—.quizsave / load (calls Tauri fs commands on desktop, File System Access API in the browser).LibraryService— device-wide media catalog with SHA-256 content-hash de-duplication.TransferService— Push to Host (mDNS discovery + WebSocket push).PreviewBridge— pushes slide JSON to the Quiz.Preview canvas; subscribes to ready / rendered / error events.PlatformAdapter— abstracts shell-specific capabilities so the eventual browser-shell port stays additive.- JS bridge — thin TypeScript wrapper around
unityInstance.SendMessage()for outbound state push, and a global JS callback table that the WebGL build's[DllImport("__Internal")]callbacks invoke for inbound ready / rendered / error events.
Tier 3 — Contracts + Unity foundation
schemas/— JSON Schema source of truth for the.quizpackage format, the WebSocket message envelopes, and every built-in object type'sobjectTypeDatashape. Codegen produces:- C# types into
com.quiz.corefor the Unity apps (viaNJsonSchemaorquicktype). - TypeScript types + runtime validators into the Angular workspace (via
quicktype+ajv). com.quiz.core(.NET Standard 2.1) — Unity-side schema, validation, scoring,.quizread/write, WebSocket protocol state machine, mDNS coordination, session state machine, snapshot/replay format. Referenced by Host / Client / Remote / Quiz.Preview.com.quiz.shared-assets— UPM package; scenes, prefabs, materials, shaders shared across the four Unity projects. Carries the slide / element rendering setup that needs to look identical between Host / Client / Preview. See Repository Layout.com.quiz.runtime— Unity-aware adapter package; main-thread marshalling, MonoBehaviour scaffolding, object-type plugin contract on the Unity side. Used by Unity projects only.
Designer business logic is not part of com.quiz.core and is not consumed via a shared library. The contract crosses the language boundary as schemas; the implementation on each side is native to that language. See Decisions for the rationale.
Pixel-fidelity trade-off
Designer preview is ~95-98% pixel-equivalent to native Host / Client. The same Unity scenes render in WebGL via WebGL2/WebGL1 shader profiles instead of D3D11 / Metal / Vulkan, with subtle drift in shadow filtering, post-processing, and AA.
- Gain: one preview integration. No HWND / NSView reparenting. No shared-texture compositing. No
--preview-flagged Host build. The same WebGL bundle slots into the eventual Web Designer Stretch without rework. - Cost: preview is not bit-identical to play. Authors validating subtle visuals run the native Host via the Run from slide action below.
No pixel-diff harness or CI gate is planned — drift is accepted up-front.
Boot sequence — splash + shell handoff
The Tauri shell opens two windows in sequence at startup: a borderless 720 × 480 splash that pre-warms the heavy work, then the main authoring shell at the user's saved window size. The splash is the only Designer surface that does not respect the Studio chrome theme — it is brand-locked Showtime and renders identically in light and dark mode. The Adobe / JetBrains / Visual Studio pattern.
- Tauri opens the splash
WebviewWindow(no decorations, no resize, always-on-top) and shows it within ~50 ms of process start. The splash WebView loadssplash.htmlfrom the bundled Angular assets. - Splash boot tasks run in parallel inside the Angular workspace: schema codegen check (TS validator hash matches the bundled schemas),
LibraryServiceindex hydration, recent-files index read,PreviewBridgewaits for the bundledQuiz.PreviewWebGL build to reportready, mDNS Rust-side handshake (Designer-discoverable false by default — we're not advertising, just initialising the listener). - Each task reports its stage to the splash UI via a Tauri event; the splash renders
Step N of 5+ a progress bar against the same brand hero in light and dark mode. - When every task resolves, Tauri opens the main shell
WebviewWindowat the user's saved size (or the mockup default of 2752 × 2064 on first launch), focuses it, and closes the splash window. The splash closes with a 120 ms fade — not a separate dialog dismissal. - If any task fails, the splash stays open with a banner ("Couldn't load schema registry — restart, or open without preview") and the user picks restart vs continue. The shell still opens; the failed subsystem surfaces a degraded-mode banner inside the shell.
The splash is not in the main shell's render tree — it is a separate WebViewWindow that closes before the main shell focuses. This keeps the splash from being themed by the shell's theme toggle and lets the splash render at a different resolution from the main authoring window.
See splash.html for the locked Showtime visual.
Multi-window
Tauri's WebviewWindow API; each tool window or palette can be a separate OS-level window, each its own decorated WebView. Real OS chrome, real OS-level focus, real OS-level minimise / maximise / close.
State sync pattern: main-window-owns-state. The primary window holds the AuthoringSession source of truth. Secondary windows (popped-out preview, popped-out properties inspector) are read-mostly views that subscribe to changes via Tauri's event broadcast (emit_all). Edits in secondary windows route through Tauri events back to the primary window's command dispatcher. This keeps the Angular state graph in one place and avoids per-window state-store reconciliation.
The eventual Web Designer Stretch reuses the same pattern via BroadcastChannel between window.open(...) instances.
Editing flow
- User opens a
.quiz(or starts a new one). AngularPersistenceServicereads via Tauri fs + thezipcrate;Quiz.CoreJSON Schema validators verify on load. - User selects a slide. Angular renders an approximate / form-driven view of the slide for fast editing.
- Angular pushes the slide's state (JSON) to
Quiz.Previewover the JS bridge.Quiz.Previewrenders the slide pixel-accurate (modulo WebGL drift). - User edits a property. Angular updates
AuthoringSessionvia a command (undoable), pushes the delta toQuiz.Preview. WebGL re-renders. - User clicks Push to Host — Angular
TransferServicecalls Tauri's mDNS browse to list reachable Hosts, then opens a WebSocket and streams the same.quiz-over-WebSocket framing the Host accepts.
Undo / redo granularity
The Designer's CommandDispatcher (an Angular service) is the authoritative source of "one undoable unit". The rules below define what gets bundled into a single command — and therefore what one Ctrl+Z reverses.
| Action | Granularity | Why |
|---|---|---|
| Add element to a canvas | one command per add | Inserting an element is a discrete action with a clean inverse (remove). |
| Move element (drag) | one command per drag gesture | The command captures the start position on mouse-down, the end position on mouse-up, and writes a single transform delta. Intermediate frames are not on the undo stack. |
| Resize / rotate | one command per gesture | Same model as move — start on handle grab, commit on release. |
| Property edit — text fields | one command per "commit" | A commit is a blur, an Enter press, or a 1500 ms idle while focused. Keystrokes during typing do not pollute the undo stack. |
| Property edit — numeric stepper, slider | one command per gesture | Drag the slider = one command; click the stepper = one command per click. |
| Property edit — boolean / enum dropdown | one command per change | Discrete by nature. |
| Reorder slides | one command per reorder | Drag-reorder = one command capturing the from/to indices. |
| Group / ungroup into round | one command per operation | Captures the slide range and the new (or removed) round. |
| Delete element / slide / round | one command | The command serialises the deleted entity for undo() to restore. |
| Paste (single or multi-select) | one command per paste | Captures all inserted ids so a single Undo removes them all. |
| File operations (Open / Save / Save As / Run from slide) | not on the undo stack | These are session boundaries, not authoring actions. |
| Transfer to Host | not on the undo stack | External side-effect; no inverse. |
The undo stack is per-quiz — Open / New replaces the active quiz and clears the stack. The stack has no hard depth cap in v1; memory pressure is acceptable up to thousands of commands on the typical 50-slide quiz.
Redo stack is the inverse — every Undo pushes onto Redo; any new command clears Redo. Standard editor semantics.
The Angular properties-inspector components mediate all property edits via a PropertyDispatcher helper that batches keystrokes and emits commands at commit boundaries — components never call CommandDispatcher.execute() directly for property edits. Direct callers are restricted to gesture-bound interactions (drag handles, palette drag-drop, slide reorder).
Library content-hash de-duplication
The device-wide asset library at platform app-data folder (LibraryService Angular service backed by Tauri's fs plugin) de-duplicates by SHA-256 so a 50 MB image imported into ten quizzes is stored once.
Layout
<app-data>/Quiz.Designer/Library/
├── blobs/
│ └── <first2-of-sha>/<rest-of-sha> # canonical content, one file per unique hash
├── index.json # the catalog
└── index.json.tmp # written + fsynced + renamed atomically
blobs/ is sharded by the first two hex chars of the SHA-256 to avoid one-folder-million-files on Windows. index.json is the only structured file; the blobs are opaque bytes.
index.json shape
{
"schemaVersion": 1,
"assets": [
{
"sha256": "9c1f...",
"byteLength": 5242880,
"mimeType": "image/png",
"kind": "image" | "audio" | "video" | "avatar" | "buzzer",
"displayName": "skyline-paris.png",
"addedAtUtc": "2026-05-13T19:00:00Z",
"lastUsedAtUtc": "2026-05-13T19:00:00Z",
"originalFilenames": ["skyline-paris.png", "paris.png"],
"refCount": 3
}
]
}
| Field | Purpose |
|---|---|
sha256 |
Canonical id; matches the blob path. |
byteLength |
Cached size for the picker UI. |
mimeType |
Sniffed at import; not trusted for security boundaries. |
kind |
Author-facing category — drives which picker the asset shows up in. |
displayName |
The most-recent import filename; surfaces in the picker. |
originalFilenames |
Audit / disambiguation trail. |
refCount |
Number of active quizzes referencing this hash via the bundle-on-save indirection (see below). |
Import algorithm
- User picks a file via the platform-adapter's file picker (Tauri dialog).
- Designer streams the file through a SHA-256 hasher (Web Crypto API, available in the Tauri WebView).
- If
index.assets[].sha256already contains this hash: - Append the imported filename tooriginalFilenamesif absent. - UpdatelastUsedAtUtc. - Do not copy the file again. - Else: copy the bytes to
blobs/<first2>/<rest>via the standard atomic-write protocol (.tmp→ fsync → rename), then add anassets[]entry. - Re-write
index.jsonatomically.
Bundle-on-save / unbundle-on-load
When the author saves a .quiz:
- The Designer walks every element referencing a library asset.
- For each unique
sha256, copy the blob's bytes into the.quizarchive'sresources/<kind>s/<sha256>.<ext>path. - The element's
objectTypeDatastores the asset as a bundled-resource id ("resourceId": "<sha256>"), so the saved package is fully self-contained.
When the author opens a .quiz:
- The Designer reads each bundled resource's bytes.
- For each one, hashes and looks up in the library.
- Match → reuse the library blob; the in-memory model rebinds the resource pointer to the library hash. The bundled bytes in the open
.quizare not re-imported. - No match → import the bundled bytes into the library (atomic write of the blob + index update), then bind.
The round-trip property: a saved-then-opened quiz produces a .quiz byte-identical to the original (modulo timestamps in the manifest), and the library contains every asset the quiz needs at content-hash resolution. The build plan's Bundle-on-save / unbundle-on-load round-trip test asserts this.
Index corruption recovery
If index.json fails to parse on Designer launch (interrupted write, disk corruption), the recovery path is:
- Move
index.jsontoindex.json.broken.<timestamp>. - Walk
blobs/and rebuild a minimal index from on-disk content:{ sha256: <filename>, byteLength: <file size>, mimeType: "application/octet-stream", kind: "unknown", displayName: "<sha256>", addedAtUtc: <file mtime>, lastUsedAtUtc: <file mtime>, originalFilenames: [], refCount: 0 }. - Re-write
index.jsonatomically. - Surface a banner: "Library index rebuilt — some asset names and categories may be missing. Re-import the asset to restore its display name."
The author loses pretty names + categorisations on a rebuild but never loses the bytes — every reference from a .quiz still resolves by hash.
Garbage collection
A blob whose refCount == 0 AND lastUsedAtUtc is older than 60 days is eligible for GC. GC runs as part of the launch-time snapshot pass. The user is not prompted; the bytes go.
refCount is updated on every Save (counts unique hashes referenced by the saved package) and on every Open (increments for assets newly imported by the unbundle path). Cross-quiz counting means a hash used by three open quizzes has refCount == 3 even if the third one was just opened from disk.
Default asset library
The Designer ships with a built-in default library of small, pre-licensed assets the author can drop into a new quiz. The library covers two asset families today:
- Buzzer jingles — a small curated set of generic clips ("ding", "buzz", "horn", "fanfare") authors can pick from when configuring
core.buzzer-inputelement behaviour and team-customisation rules (F-DE-29). - Premade avatars — a starter avatar set authors can hand to teams as a photo-free alternative (F-DE-30).
The library lives inside the Designer install (under the Tauri app bundle's resources/, not in the quiz). When the author picks a clip / avatar from the library and adds it to the active quiz, the Designer copies the file into the in-memory .quiz package's resources/audio/buzzers/ or resources/avatars/ folder. The selection is registered in the manifest's buzzers[] / avatars[] array. The package therefore stays self-contained — a Host that doesn't have the Designer installed still has every byte it needs to play the chosen jingle or render the chosen avatar.
Library contents and licensing (resolved 2026-05-11):
| Family | Count in v1 | Source / licence |
|---|---|---|
| Buzzer jingles | 10 | CC0 / public domain (Freesound CC0 archives). Zero attribution needed; distributable inside any author-shipped .quiz. |
| Premade avatars | 24 | CC0 / public domain (OpenGameArt CC0 archives or commissioned-then-released-CC0). Same distribution terms. |
Library updates ride app releases — no content endpoint, no auth, no extra infrastructure in v1. New clips ship when the Designer ships. The author is expected to bring their own assets for any "this is my quiz personality" moment beyond the starter set.
Run from slide — local Host process spawn
Per F-DE-27, the Designer can launch a local Quiz.Host process on the same machine starting at the currently-selected slide. This is the toolbar ▸ Run CTA (and F5 from start, Shift+F5 from current slide — PowerPoint convention). The Designer:
- Saves the in-memory quiz to a scratch
.quiz(or uses the on-disk path if clean). - Calls the Tauri process plugin:
Command::new("Quiz.Host").args(["--quiz", path, "--start-at-slide", index, "--launched-from-designer"]).spawn(). - The Host opens in operator-window mode (F-HO-25) if multiple displays are connected — audience window on the primary external display, operator window on the laptop. On a single-display machine the operator surfaces are an overlay.
This is local-machine-only. Network push (Bonjour discovery → pick a remote Host) stays under File → Push to Host (F-DE-14).
When the Web Designer Stretch lands, this surface degrades — browsers cannot spawn processes. The web-side analog is to push the in-memory .quiz to a Host that is already running on the LAN (or hand-paired by code) and send a control-envelope command containing openAtSlide: <index>. The wire-level message is the same one the desktop subprocess path uses internally so the Host code only implements it once. See Networking.
Custom titlebar
The mockups under docs/ui-mockups/designer/ merge titlebar + toolbar into a single Rider-style 44 px strip: logomark + wordmark + menus (left), doc title + dirty dot (centre), history + autosave + Run CTA (right).
The titlebar is an Angular component (<app-titlebar>). The Tauri shell sets decorations: false and uses titleBarStyle: "Overlay" on macOS so the OS draws the traffic lights and the Angular component draws everything else; on Windows 11 the shell sets windowEffects: { effects: ["mica"] } so the Mica blur shows through the transparent CSS background. The same component renders inside ng serve (no native chrome behind it) so visual verification against the mockups stays sound.
The mockups show the macOS traffic-light cluster because that's the design-reference platform — the running titlebar adapts at runtime per host OS so users get the conventions they expect.
Window-control convention per platform
Angular detects the host OS via a small PlatformService (sniffs navigator.userAgent — works under both WebView2 / WKWebView and ng serve Chromium). The titlebar branches on the result:
| Host OS | Caption buttons | Where drawn | Padding reserve |
|---|---|---|---|
| macOS | Real traffic-lights (red / amber / green) | OS, inside the WebView region via titleBarStyle: "Overlay" + setTrafficLightPosition |
80 px on the left edge so the brand never sits under them |
| Windows 10/11 | Flat Win-11-style glyph buttons (min ─, max □, close ✕), 46×32 px each | Angular <app-titlebar>, right edge |
None — Angular owns the right edge |
| Linux | Same Win-style glyph buttons | Angular <app-titlebar>, right edge |
None |
The choice of "macOS = OS-drawn, everyone else = Angular-drawn" follows from Tauri 2: titleBarStyle: "Overlay" is mac-only; on Windows / Linux decorations: false removes the native caption buttons entirely, so Angular has to draw them itself. The hover colours follow each platform's convention — Win 11 close-button hover is #E81123; min / max hover is the chrome's neutral --chrome-hover.
Wiring — Tauri desktop shell
Tauri tauri.conf.json declares the window with decorations: false, the platform-specific titlebar-style flags above, and an initial size. The Rust side exposes window.minimize(), window.toggleMaximize(), and window.close() to the Angular side via @tauri-apps/api/window.
The Angular <app-titlebar> defines the window-drag region with -webkit-app-region: drag (WebView2 + WKWebView both honour it; interactive children opt out with no-drag). The per-platform caption-button + padding rules above sit on top of that.
Titlebar content
Logomark SVG + wordmark + File / Edit / View / Help dropdowns (left), document name + dirty dot bound to AuthoringSession (centre), Undo / Redo + autosave badge + Run-from-slide CTA bound to CommandDispatcher + AuthoringSession (right). Menu actions route through the shared MenuActions service. Native OS menus on the menu bar (where applicable on macOS) are wired through tauri-plugin-menu and call the same MenuActions entries.
Theming
The titlebar inherits the Studio chrome tokens from the Angular global styles (data-theme attribute on <html>) — light / dark is a CSS-variable swap, no rebuild. Tauri-side native chrome (traffic-light position, Mica accent) is updated once on theme change via setTrafficLightPosition / Tauri commands.
Dev-iteration loop
ng serve is the dev-iteration host — purely development infrastructure, not a shipped product. Playwright drives Chromium pointing at http://localhost:4200 directly because it cannot drive WebView2 / WKWebView (which is what the Tauri shell hosts) directly.
ng serve(defaulthttp://localhost:4200).python .claude/skills/ui-mockups/scripts/screenshot.py --url http://localhost:4200/writes a PNG to.build/.- Compare against the matching mockup under
docs/ui-mockups/designer/.
Visual divergence from the mockup is a blocker per CLAUDE.md — UI work — visual verification before sign-off. Type-checking and Angular tests are not sufficient.
The Tauri-specific surface (custom titlebar with OS chrome behind it, multi-window, mDNS, subprocess spawn) is verified manually via tauri dev (Tauri shell + Vite dev server). Tauri-only code paths (e.g. native menu bindings) cannot be exercised under ng serve alone.
Repository Layout
How the apps and their shared contracts are arranged on disk in this monorepo. The high-level decision — schemas as the single cross-language contract; Unity apps share a C# class library via UPM; the Designer consumes the schemas as generated TypeScript — is recorded in Decisions; this page is the concrete on-disk realisation.
Top-level structure
Quiz/
├── Quiz.Designer/ # Tauri 2 + Angular 21 workspace — desktop authoring shell (Win + Mac)
│ ├── src-tauri/ # Rust shell — main.rs, tauri.conf.json, plugins, build.rs
│ ├── src/ # Angular workspace — app/, services/, components/, generated/
│ ├── public/ # Static assets bundled into the WebView
│ ├── e2e/ # Playwright specs driven against ng serve
│ ├── angular.json
│ ├── package.json
│ └── tsconfig.json
├── Quiz.Host/ # Unity project — TV-show app
├── Quiz.Client/ # Unity project — team device app
├── Quiz.Remote/ # Unity project — quizmaster controller
├── Quiz.Preview/ # Unity project — WebGL slide-render target embedded in Designer
├── schemas/ # JSON Schema source of truth — codegen targets C# + TypeScript
│ ├── package-format/ # .quiz manifest, slides, resources
│ ├── live-play/ # WebSocket message envelopes
│ ├── transfer/ # Designer → Host transfer framing
│ ├── object-types/ # Per-type objectTypeData payloads
│ └── codegen.config.json # quicktype / NJsonSchema configuration
├── packages/ # Shared local UPM packages (source-on-disk; Unity apps only)
│ ├── com.quiz.core/ # Pure C# (.NET Standard 2.1) — schemas, DTOs, scoring, protocol
│ ├── com.quiz.runtime/ # Unity-aware shared runtime — registry, networking glue
│ └── com.quiz.shared-assets/ # Cross-app prefabs, fonts, brand assets, shaders
├── docs/ # Generated PDF/HTML artefacts (derived; do not edit)
└── .claude/ # Knowledgebase, skills, context
Quiz.Designer/ is a Tauri 2 workspace with an Angular 21 application inside it. The Rust shell under src-tauri/ is config-heavy and code-light: window setup, plugin wiring, IPC commands for file dialogs / mDNS / subprocess spawn. The Angular workspace under src/ holds every authoring surface, every service, and the platform-adapter layer that abstracts shell-specific capabilities. See Designer Shell.
Quiz.Preview/ is a Unity project, sibling to Quiz.Host / Quiz.Client / Quiz.Remote, that builds only for WebGL and is consumed by the Designer as its embedded slide-render target. The WebGL build output is published as a CI artefact and bundled into the Tauri app's src/assets/preview/ directory at Designer build time, then loaded inline as a <canvas> via the standard Unity WebGL loader.
Each Unity project sits at the repo root as a sibling, not nested. Unity needs an entire project tree per app (Assets/, ProjectSettings/, Packages/, Library/, etc.) and these trees do not nest cleanly. Sibling folders is the only layout Unity tolerates without per-platform symlink gymnastics.
Because four Unity projects can be open in Unity Editor at the same time, AI tooling that drives the editor must route each call to the correct editor instance. See Unity MCP for the routing protocol — without it, edits land in the wrong app's Assets/ tree.
The on-disk shape inside each Unity project's Assets/_Game/ is documented in Per-App Scaffolding.
Cross-language contract — schemas/
schemas/ is the source of truth for every data shape that crosses the Designer ↔ Unity boundary. Files are written as JSON Schema (draft 2020-12). Codegen runs in CI before each side builds:
- C# generation —
NJsonSchema.CodeGeneration.CSharpproduces records intopackages/com.quiz.core/Runtime/Generated/. Generated files are git-ignored; CI regenerates on every build. - TypeScript generation —
quicktypeproduces interfaces +ajv-compiled validators intoQuiz.Designer/src/app/generated/. Generated files are git-ignored; the Angular dev server regenerates on watch.
A single schemas/codegen.config.json declares the inputs, outputs, and naming conventions. Both codegen runs are wired into:
npm run schema:geninsideQuiz.Designer/(Angular CLI hook).- A
quiz-schemas.ymlADO pipeline that generates and publishes both code drops as pipeline artefacts the per-app builds consume.
The Designer side also bundles each schema's ajv validator into the Angular app so save / load / paste-from-clipboard / push-to-Host validation runs identically client-side.
Semantic rules that can't be expressed as JSON Schema constraints (cross-element invariants, custom object-type validators) are implemented twice — once in TypeScript inside Angular services, once in C# inside com.quiz.core. Both implementations are asserted against shared fixtures in schemas/fixtures/ to keep them in sync. This is the accepted cost of the language-boundary split — see Decisions.
Shared code via local UPM packages (Unity apps only)
Shared Unity code lives under packages/ as one or more local UPM packages. Each Unity project's Packages/manifest.json references them with a relative file: path:
{
"dependencies": {
"com.quiz.core": "file:../../packages/com.quiz.core",
"com.quiz.runtime": "file:../../packages/com.quiz.runtime",
"com.quiz.shared-assets": "file:../../packages/com.quiz.shared-assets"
}
}
file: references are not a registry round-trip — Unity reads the source files directly out of packages/. Edits made from any of the four projects propagate live with hot reload. The package.json inside each package is the minimal sentinel Unity needs to recognise a folder as a package; it is not a packaging or publishing step.
These packages are not consumed by the Angular Designer. The cross-language contract is the schema codegen above.
Why local UPM packages, not symlinks or DLL drops
| Option | Why not |
|---|---|
Symlinks / NTFS junctions from each Assets/ into a shared folder |
Junctions don't replicate cleanly across git clone; symlinks need core.symlinks=true on Windows; both can produce duplicate-import warnings or GUID churn if two projects ever resolve the same asset via different absolute paths. |
DLL drop into Assets/Plugins/ |
Loses jump-to-definition, no live edits across apps, requires a build step in the shared library on every change. Acceptable as a fallback but a worse developer experience. |
| Git submodule for shared code | Adds a git submodule update step to every clone and every shared-code change, with no benefit over a local folder in the same repo. |
Local UPM packages give source-level edits, stable asset GUIDs, and zero extra steps for contributors — git clone produces a working tree.
Package responsibilities
com.quiz.core
- Target:
.NET Standard 2.1, noUnityEnginereferences. - Contents: quiz schemas (codegen'd from
schemas/), DTOs, scoring rules, message envelopes, object-type plugin contract interfaces, the WebSocket protocol state machine and socket lifecycle, the mDNS coordination logic, the session state machine, the snapshot/replay format. Sockets and discovery are business logic, not engine logic — see Separation of Concerns. - Why pure C#: so headless test harnesses, future server-side tooling, and CI-only runs can reference it without a Unity install.
UnityEngineAPIs are main-thread-only, and socket / mDNS work happens on background threads anyway, so making the constraint architectural is cheaper than enforcing it per-class. - Asmdef:
Quiz.Core.asmdef,noEngineReferences: true.
com.quiz.runtime
- Target: Unity-aware (depends on
UnityEngine). - Contents: Unity adapters around the engine-free networking and protocol code in
com.quiz.core— main-thread marshalling,MonoBehaviourlifecycle binding, View / ViewController scaffolding, the object-type registry implementation that wiresIObjectTypeHost/Client surfaces to UGUI prefabs. - Depends on:
com.quiz.core. - Why separate from core: keeps the pure-C# library testable headless; everything that touches Unity APIs lives here. This layer is intentionally a thin adapter — see Separation of Concerns — How the Unity-aware layer adapts engine-free code.
- Asmdef:
Quiz.Runtime.asmdefreferencingQuiz.Core.
The package split also drives where each kind of test lives — pure-C# edit-mode tests in com.quiz.core/Tests/, Unity-aware play-mode tests in com.quiz.runtime/Tests/, per-app play-mode tests in each Quiz.{App}/Assets/_Game/Tests/, and Angular tests in Quiz.Designer/src/**/*.spec.ts (Jest) plus Quiz.Designer/e2e/ (Playwright). See Testing for the full test layout and TDD convention.
com.quiz.shared-assets
- Contents: brand fonts, palette ScriptableObjects, mascot rig, shared object-type UGUI prefabs, shaders, audio stings, and the slide / element rendering setup shared between
Quiz.Host,Quiz.Client, andQuiz.Previewso the Designer's WebGL preview matches what Host / Client render at quiz time. - Depends on:
com.quiz.runtime(so prefabs can reference sharedMonoBehaviourtypes). - Why a separate package: assets evolve on a different cadence from logic, and the GUID stability guarantee is most valuable here — every cross-app prefab reference resolves through a single on-disk location. Used by all four Unity projects (
Quiz.Host,Quiz.Client,Quiz.Remote,Quiz.Preview).
The split is conservative — if any package ends up trivially small at MVP, it can be folded into the next one up. Going the other way (splitting later) is harder because callers fan out.
Designer project graph
The Designer side of the repo is a Tauri + Angular workspace. The Rust shell and the Angular app are independent build units; they meet through Tauri's IPC.
Quiz.Designer/
├── src-tauri/ # Rust — Tauri 2 shell + plugin wiring
│ ├── src/main.rs # shell bootstrap, command registrations
│ ├── src/commands/ # Rust functions exposed to the Angular side
│ │ ├── fs.rs # .quiz read/write via zip crate
│ │ ├── mdns.rs # mdns-sd browse + advertise
│ │ ├── process.rs # Quiz.Host subprocess spawn
│ │ └── window.rs # titlebar setup, traffic-light positioning
│ ├── Cargo.toml
│ └── tauri.conf.json # window decorations, plugins, capabilities
├── src/ # Angular 21 application
│ ├── app/
│ │ ├── core/ # AuthoringSession, CommandDispatcher, DI bootstrap
│ │ ├── services/ # PersistenceService, LibraryService, TransferService, PreviewBridge
│ │ ├── platform/ # PlatformAdapter interface + TauriPlatformAdapter implementation
│ │ ├── components/ # Shell, SlideList, Palette, PropertiesInspector, LibraryPanel, dialogs
│ │ ├── object-types/ # Per-type editor components (core.text, core.multiple-choice-input, …)
│ │ └── generated/ # codegen'd TS types + ajv validators (git-ignored)
│ ├── assets/
│ │ └── preview/ # Quiz.Preview WebGL bundle (CI artefact, git-ignored)
│ ├── styles/ # Studio chrome tokens, design-spec CSS
│ ├── index.html
│ └── main.ts
├── e2e/ # Playwright specs — driven against ng serve
├── angular.json
├── package.json
└── tsconfig.json
| Folder | Purpose |
|---|---|
src-tauri/ |
Rust shell. ~200 LoC of glue. Calls Tauri 2 plugins; exposes commands to TypeScript. Not where business logic lives. |
src/app/core/ |
Angular DI bootstrap, AuthoringSession, CommandDispatcher. Business-logic ground truth on the Designer side. |
src/app/services/ |
Service interfaces + implementations: persistence, library, transfer, preview bridge. Talk to PlatformAdapter for shell-specific operations. |
src/app/platform/ |
PlatformAdapter abstraction. TauriPlatformAdapter implements it via Tauri commands. The eventual Web Designer Stretch lands a BrowserPlatformAdapter against browser APIs without touching components or services. |
src/app/components/ |
Angular standalone components — every authoring screen. |
src/app/object-types/ |
Per-type editor components. Each implements the Designer-side surface of the object-type plugin contract (see Object-Type Contract). |
src/app/generated/ |
Codegen'd TypeScript types + ajv-compiled validators derived from schemas/. Regenerated on npm run schema:gen. |
src/assets/preview/ |
WebGL build output of Quiz.Preview/. Bundled at Designer build time; loaded inline via Unity's WebGL loader. |
e2e/ |
Playwright specs that drive ng serve for visual / interaction verification. |
Assembly definitions and dependency direction (Unity side)
Game-side assemblies in each app → Quiz.Runtime → Quiz.Core
↓
(Unity engine APIs)
App-specific code references Quiz.Runtime and (transitively) Quiz.Core. The shared packages never reference app-specific code. This mirrors the module discipline documented in the unity-development skill's modules page: packages are modules, only one level higher up the tree.
Adding a new Unity project later
If a fifth Unity app is ever introduced, the steps are:
- Create the Unity project as a sibling at the repo root (
Quiz.{Name}/). - Add the three
file:package entries to itsPackages/manifest.json. - Reference the shared assemblies from its game-side asmdef.
No changes to packages/ are needed — the shared packages stay symmetrical across all apps.
Adding a new shared Unity package later
If a new shared Unity concern emerges that doesn't fit the existing three (e.g. a com.quiz.test-utilities for cross-project test fixtures):
- Create
packages/com.quiz.{name}/with apackage.jsonandRuntime/(and optionallyEditor/andTests/) folders, each with its own asmdef. - Add a
file:entry to every Unity project'sPackages/manifest.jsonthat needs it. - Document the new package's responsibility in this page's "Package responsibilities" section.
Adding a new schema
- Add the JSON Schema file under the right
schemas/<area>/directory. - Add it to
schemas/codegen.config.jsonwith the desired output names for C# and TypeScript. - Run
npm run schema:gen(Angular side) and the equivalent C# codegen target (Unity side) — or push and let CI regenerate. - The new types are now consumable on both sides; cross-fixtures live in
schemas/fixtures/if a semantic rule needs paired assertions.
Separation of Concerns: Business Logic vs Engine Logic
A guiding architectural principle, applied across the four Unity apps (Host, Client, Remote, Quiz.Preview): business logic is pure C# and lives in com.quiz.core; engine concerns live in com.quiz.runtime and per-app code. The boundary is enforced by noEngineReferences: true on the Quiz.Core asmdef — com.quiz.core cannot accidentally take a UnityEngine dependency.
The Designer is a separate stack (Tauri + Angular, TypeScript) and does not consume com.quiz.core. Designer business logic is implemented natively in TypeScript inside Angular services; cross-app wire compatibility is guaranteed by the schema-first contract in schemas/ codegen'd into both sides — see Repository Layout — Cross-language contract.
This page is the why and the rule of thumb for the Unity side. The on-disk realisation is in Repository Layout; the load-bearing decision behind the schema-first split is in Decisions.
What counts as business logic (lives in com.quiz.core)
- Quiz schemas, DTOs, manifest format, slide / element / canvas data models.
- Scoring rules and computation — including late-submission rules, tiebreakers, per-round and per-slide configurations.
- Session state machine — current slide pointer, team list, scores, per-element state, the rules for legal transitions.
- Live-play protocol — message envelope schemas, the state machine that interprets them, the dispatch table that routes typed messages to handlers.
- WebSocket lifecycle — accepting connections, reading/writing frames, reconnect logic, backoff. The actual socket I/O happens on background threads with no
UnityEnginecalls. - Service-discovery coordination — the mDNS protocol logic, advertised-record state, "host discovered" event generation.
- Quiz package format I/O — reading and writing
.quizarchives, manifest validation against the registered object-type catalogue. - Object-type plugin contract interfaces (
IObjectType, the schema accessor, the protocol-extension hooks). - Crash recovery snapshot / replay — serialisation and deserialisation of session state, validation that a snapshot matches the loaded
.quiz.
What counts as engine concerns (lives in com.quiz.runtime or per-app code)
- Rendering — UGUI canvases, shaders, particles, post-processing.
- Audio playback —
AudioSource, mixers, ducking. - Animation — Animator, Timeline, DOTween tweens.
- Input handling — touch, keyboard, gamepad, stylus.
- Prefab instantiation, scene loading, addressables.
MonoBehaviourlifecycle binding —Awake,OnEnable,Update, etc.- Main-thread marshalling — bridging events that arrived on background threads (sockets, mDNS) to the Unity main thread so view code can react.
- View / ViewController scaffolding wiring
IObjectTypeHost/Client surfaces to UGUI prefabs.
Why this boundary
- Headless testability. The protocol state machine, scoring rules, and snapshot round-trip can run in a plain
dotnet testharness with no Unity install. Tests are fast and reliable; CI doesn't need a Unity license to run them. - Concurrency correctness.
UnityEngineAPIs are main-thread-only. The WebSocket server and mDNS listener necessarily run on background threads. If networking lived in the Unity-aware layer it would have to either block the main thread or build its own thread-marshalling discipline anyway — putting it incom.quiz.coremakes the constraint architectural rather than per-class. - Single source of truth across the Unity apps. Host, Client, Remote, and Quiz.Preview all need to interpret the same schemas and message envelopes the same way. The shared pure-C#
com.quiz.corelibrary guarantees that for the Unity side — the same types, the same validators, the same state machine. The Designer keeps in sync via schema codegen rather than by sharing the library. - Forces explicit interfaces. Crossing the boundary requires defining an event, callback, or interface. That friction is good — it surfaces hidden coupling instead of letting "view code reaches into protocol code" creep in.
- Forward compatibility. If part of the system ever moves server-side (the Stretch cloud-backed authoring path, an internet-relay server) or a future client uses a different engine, the business-logic library is portable as-is. v1 doesn't depend on this — it's a free option.
How the Unity-aware layer adapts engine-free code
com.quiz.runtime is intentionally a thin adapter, not a replication of business logic. The shape is consistently:
- Instantiate the engine-free objects from
com.quiz.core(e.g.WebSocketServer,SessionStateMachine,MessageDispatcher). - Subscribe to their events on whatever thread they fire on.
- Marshal those events to the Unity main thread (via
SynchronizationContextor a queue drained inUpdate). - Re-emit them as Unity-friendly C# events /
UnityEvents for views and game code to consume. - On the way back out (player input, view actions), call into the engine-free objects directly from the main thread — they're thread-safe at their public API, or document where they aren't.
If a method in com.quiz.runtime is doing more than adapt / marshal / wire-up — if it's deciding something about scoring, protocol state, or session validity — that decision belongs back in com.quiz.core.
Rule of thumb when in doubt
Could this code, conceptually, run on a server with no display? If yes, it's business logic — put it in
com.quiz.core. If it would be meaningless without a screen, an audio device, or an input device, it's an engine concern — put it incom.quiz.runtimeor per-app code.
The socket layer answers "yes" to that question — sockets don't need a display. So sockets live in core.
Quiz Package Format
Quizzes are stored and transferred as .quiz files (zip archives) with the following structure:
mypub_quiz_v3.quiz
├── manifest.json # Quiz metadata, schema version, ordered slide list,
│ # round groupings, declared object-type registry,
│ # buzzers[] + avatars[] registries
├── slides/
│ ├── slide_001.json # Slide def: id, title, round_id, timing, scoring,
│ │ # host_canvas, client_canvas
│ ├── slide_002.json
│ └── ...
└── resources/
├── images/ # general slide imagery referenced by elements
├── audio/
│ ├── slides/ # general slide audio referenced by elements
│ └── buzzers/ # author-bundled buzzer jingles (F-DE-28, F-CL-15)
├── video/
└── avatars/ # author-bundled premade avatars (F-DE-30, F-CL-14)
A .quiz package contains data only — no runtime code. Each element on a slide references an object type by id (e.g. core.text) and version. The Designer can only emit packages whose declared type ids/versions match its own built-in registry; the Host and Client refuse to load a package whose declared types they do not have built-in. Bundle-supplied object types (a object_types/ folder shipping C# behaviours + prefabs alongside the data) are deferred to a stretch goal — see Open Questions.
A slide is the unit of presentation. Each slide carries a Host canvas (a fixed 1920×1080 virtual canvas, scaled to fit the connected display) and a Client canvas (a responsive layout that adapts across phone and tablet form factors). Both canvases hold elements; the same slide can have completely different content on each. (For term definitions see the Glossary.)
An element is a placed instance of an object type with its own per-instance properties. An object type is a pluggable module — see Object-Type Architecture.
A round is a contiguous range of slides sharing a title and scoring metadata — equivalent to a PowerPoint section. Rounds are optional metadata over the slide list, not the unit of presentation.
The manifest.json is validated against a C# schema in the shared class library, so the Designer cannot produce — and the Host cannot accept — a malformed package. The manifest also declares every object type the quiz uses (with required schema versions); apps refuse the package if any declared type is missing or version-incompatible against the app's built-in registry.
The field-level JSON shape of manifest.json, every slides/*.json, the shared element block, and every built-in object type's objectTypeData payload is canonical in Slide Schema. The C# types in com.quiz.core reflect that page.
A quiz package declares its overall schema version in the manifest. The Host gracefully refuses or upgrades older packages. Maximum package size is 200 MB, capped to keep the always-eager-push live-play model viable on older mobile devices (2–3 GB RAM). Authors needing more than 200 MB of media in a single quiz are out of scope for v1.
The manifest also declares a theme (an enum: dark (default), light, plus brand presets) that the Host, Client, and Remote render the quiz against. The Designer's chrome theme is independent of this — an author may author in light theme but ship a dark-themed quiz. Per-quiz custom palette objects (richer than the enum) are tracked in Stretch. Theming overall is Beta scope; MVP and Alpha render against the default dark theme regardless of manifest declaration.
Resource bundling — bundle-on-save, unbundle-on-load
Every resource referenced from a slide or the manifest is identified by a SHA-256 content hash, not by filename. Resources live in resources/{family}/{hash[:2]}/{hash}.{ext}; slides and the manifest reference them by hash. Identical bytes used in two different slides produce one file in the archive.
The Designer keeps a device-wide LibraryService cache of every resource the author has ever imported, keyed by the same SHA-256 hash. Two operations span the boundary between archive and library:
- Bundle-on-save. When the Designer writes a
.quiz, every resource the manifest or any slide references is copied from the library into the archive'sresources/tree. The archive is self-contained — a Host with no Designer installed plays the quiz unchanged. - Unbundle-on-load. When the Designer opens a
.quiz, each archive resource is content-hash-de-duplicated against the local library. New hashes are imported into the library; matching hashes are reused — the archive bytes are discarded once the library has its own copy.
The invariant: save → open round-trip preserves every resource id and bytes match. Round-trip serialisation tests under schemas/fixtures/packages/ assert this on every codegen change.
Resources are written only when the manifest or a slide references them. The Designer's library can grow indefinitely on disk; an unreferenced library asset never enters any .quiz. The Host never imports into a library — its resources/ tree lives only inside the loaded package directory.
Team-customisation resources
Two resources/ sub-buckets exist solely to feed the team join flow at runtime; neither is referenced by slide elements:
resources/audio/buzzers/— short author-supplied audio clips, one per file. Each clip is registered inmanifest.jsonunder the top-levelbuzzers[]array as{ id, name, file, duration_ms }. At join, the Client lists these bynamefor the captain to preview + pick from (F-CL-15). At runtime, the Host plays the picked clip when that team wins a buzzer race (F-HO-27). Clips are copied into the bundle when the author picks from the Designer's default library (F-DE-29) so the package is self-contained — a Host that doesn't have the Designer installed still has everything it needs to play the chosen jingle.resources/avatars/— author-supplied premade avatar images. Each is registered inmanifest.jsonunderavatars[]as{ id, name, file }. The Client offers these as a fallback when the team captain declines the camera permission or doesn't want to use a photo (F-CL-14).
Both arrays are optional. If absent, the Client hides the buzzer picker (Host falls back to a silent buzz tone) and the avatar picker (Client only offers photo capture).
Photos captured by teams at join are not stored inside the .quiz — that is a session-scoped artefact, transmitted over the WebSocket join message and held in the Host's session snapshot only. See Networking.
Designer→Host transfer
The Designer→Host transfer of a .quiz file over local Wi-Fi is described in Networking.
Networking
Local Wi-Fi (primary path, v1)
The Host runs an in-process WebSocket server on its local network interface. It advertises itself on the local network via Bonjour/mDNS. The same server handles three message families:
- Designer transfer — receiving
.quizpackage transfers from a Designer. - Client live-play — running live quiz sessions with team Clients.
- Remote control — accepting control connections from the Remote app. MVP carries the minimum viable controls (discovery, pairing, mirror, host-notes, live state, advance/go-back); the rich control command set lands in Alpha.
Each family has distinct typed envelopes declared as JSON Schema under schemas/live-play/ and codegen'd into both the Unity-side com.quiz.core library (C#) and the Designer Angular workspace (TypeScript). They share one transport, one TLS posture, and one server lifecycle.
Designer→Host package transfer
When the Designer is in "send to Host" mode, it discovers Hosts on the local network via the Bonjour advertisement, the author picks one, and the Designer pushes the .quiz file (already saved to local disk via the export action) to the chosen Host over the WebSocket connection.
Gating: manual confirm per push. The Host shows a prompt — "Designer X wants to send 'My Quiz' (50 MB). Accept?" — every time a transfer arrives. One tap to accept. Same-LAN auto-accept was rejected because pub Wi-Fi is shared and a stranger on the network pushing nonsense to the Host would be operationally bad. A future PIN-pairing flow that lets a known Designer auto-accept thereafter is possible but not in v1.
The Host receives the package, validates the manifest, resolves every declared object type against its built-in registry (see Object-Type Architecture), refuses on any mismatch, and otherwise stores it locally for play.
Wire-level details (resolved 2026-05-11):
| Aspect | Pinned default |
|---|---|
| Chunk size | 64 KB per WebSocket frame |
| Progress reporting | Sender pushes a transfer-progress event after each chunk is acknowledged by the receiver |
| Integrity | CRC32 per chunk; receiver verifies, requests re-send of any failed chunk |
| Resume after disconnect | Receiver tracks offset on disk; sender resumes from the last acknowledged offset on reconnect |
| Wire compression | None (.quiz is already a zip archive — extra compression wastes CPU on both ends) |
| Max package size | 200 MB (see Quiz Package Format) |
The full framing schema lives in Transfer Protocol.
The on-disk format being transferred is described in Quiz Package Format.
Live play and per-Client distribution
Clients discover the Host via the same Bonjour service, connect, and exchange typed messages defined in the shared schema under schemas/live-play/ (codegen'd to C# inside com.quiz.core). When a Client joins a session, the Host eagerly pushes the slides' Client-canvas content plus the resources those elements reference (images, audio clips that play on the Client, etc.) over the WebSocket. Buzzer-jingle audio (see Quiz Package Format) is not pushed to Clients — those clips play on the Host, not the Client, and the Client only needs the list of { id, name, file } records to render the join-time picker. The Client downloads each individual jingle file lazily when the team taps "preview" on the picker.
Scale. A session supports up to 200 concurrent Client devices per Non-Functional Requirements (typical session ~30). At 200 teams the eager-push is fanned out 200× — the Host writes the same chunk stream to every connection in parallel. Per-Client backpressure (slow Wi-Fi on one device) does not stall other Clients; the Host streams independently per peer. The 200 MB package cap is the limiting factor here — 200 clients × 200 MB = 40 GB of total push bytes, well within the Host's local-LAN bandwidth budget over a 30-minute window even on consumer Wi-Fi 6.
Team join — identity payload
The team join message is a single WebSocket frame that carries everything the Host needs to register a new team:
| Field | Type | Required | Notes |
|---|---|---|---|
team_name |
string | ✓ | Per F-CL-2. |
team_colour |
string (token from .quiz manifest) |
Beta only | Per F-CL-12. |
avatar_choice |
{ kind: "photo", jpeg: <base64>, w, h } \| { kind: "premade", avatar_id: <manifest avatars[].id> } |
✓ from Alpha onwards | Per F-CL-14. Photo is JPEG, square, 256 × 256 px, quality 75, 96 KB hard cap (re-encode at quality 60 on overflow) — Client downscales + encodes before sending. Premade refers to a manifest.avatars[].id (Designer-bundled, see package format). |
buzzer_jingle_id |
string | null | Alpha (if quiz has buzzers) | Per F-CL-15. References manifest.buzzers[].id. null if the quiz bundles no jingles or the team didn't pick one. |
client_token |
string | ✓ from Alpha onwards | Per-Client persistent identity for crash-recovery rejoin (F-CL-10). |
The Host validates the payload, stores the photo / jingle-choice in its session snapshot (so crash recovery preserves them), and broadcasts a "team joined" event to the rest of the session participants.
The photo is held in memory by the Host and written to the session-snapshot file for crash recovery; it is cleared at session end unless cloud-backed team identity (F-HO-23 Stretch) is enabled.
Joining mid-quiz: progress UI + jump-to-current. A Client joining after the quiz has begun shows a progress bar while the eager push transfers; on completion, the Client jumps directly to the slide the quiz is currently on. Earlier rounds are not replayed (acceptable for the live-quiz format — a late team picks up where the room is). Slides advanced during the eager push are queued and applied in order when the push completes.
Timer authority and clock sync. The Host is authoritative on time. Clients render a local countdown that periodically reconciles with the Host's "time-remaining" tick at 5 Hz (every 200 ms; ±500 ms reconciliation tolerance). On time-up, the Host emits a "lock" message; Clients stop accepting input. Submissions arriving after the lock are rejected — unless the slide is configured (by the author) to accept late submissions, in which case the Host applies the configured scoring rule (no penalty / fixed penalty / per-second decay). The quizmaster (via the Remote, or directly on the Host) can override the timer for a slide live: extend, skip, manually lock, or manually unlock. Tick cadence, reconciliation tolerance, override semantics, and the full message catalogue live in Live Play Protocol.
Reliability requirements:
- A single client misbehaving must not affect other clients or the Host.
- The Host must survive client disconnects and reconnects gracefully (a reconnecting Client may re-receive the eager push or a delta).
- The Host must tolerate brief Wi-Fi instability and brief backgrounding.
- A team device dying or backgrounding the Client app does not crash the session.
The detailed message envelopes, lifecycle, and reconnection semantics live in Live Play Protocol.
Crash recovery (Alpha onwards)
The Host snapshots session state to disk after every scoring event and every slide advance. State is sufficient to resume: current slide pointer, team list (team_id, team_name, score), per-element state where the element flagged itself "stateful" (e.g. a timer's remaining time at the moment of snapshot, a leaderboard's last reveal index), and the live-play protocol's last sequence number per Client.
If the Host process crashes, the operator relaunches the Host. On launch with a saved session that matches the loaded .quiz, the Host prompts the operator to resume or start fresh. Resume restores the snapshot; the Host re-advertises on Bonjour; Clients reconnect using their persisted team identity (a stable token written to local storage on first join) and re-attach as their original team with their score intact. The reconnecting protocol path is the same one used for Wi-Fi-blip recovery; the only difference is the Host's state was rebuilt from disk rather than from memory.
This is a v1 capability, not a Stretch goal — it lands in the Alpha phase. MVP runs the disconnect/reconnect protocol but does not persist state across a Host restart.
Remote control
The Remote pairs with one Host at a time. Discovery is the same Bonjour advertisement Designers and Clients use. Pairing is gated — manual confirm on the Host the first time a given Remote connects, or QR-code pairing where the Host shows a code that the Remote scans (final UX is a build-plan decision).
Once paired, the Remote opens a control-message-family WebSocket to the Host. Messages flow both ways. The MVP and Alpha cuts of this protocol are:
| Direction | MVP — minimum viable controls | Alpha — rich control commands |
|---|---|---|
| Host → Remote | Periodic mirror of the Host canvas (scaled-down). Per-slide host-notes from the loaded .quiz. Live state — current scores, current timer remaining, current slide index. |
(No additions — the same telemetry stream supports the richer commands.) |
| Remote → Host | Advance, go-back. | Jump to a specific slide. Trigger element reveals (e.g. show the leaderboard, show the answer). Lock / unlock Client input. Extend / skip the timer. Override scoring per team. |
The MVP control-message envelope schema must reserve room for the rich commands so adding them in Alpha is purely additive — no protocol break.
The Remote does not expose any participant-facing surface. It is the quizmaster's tool, not a team's tool.
A session supports zero or one Remote in v1. Multi-Remote (co-quizmasters on separate phones) is not in scope.
Internet-based play (future)
Architecturally accommodated, not a v1 feature — see Stretch. The plan is an optional relay (Cloudflare Durable Objects or similar) that proxies WebSocket traffic between Host and Clients in different locations. Same protocol; different transport.
Session-code join
Discovery shifts from Bonjour to a host-issued session code (F-X-6 Stretch) — a short alphanumeric token (e.g. Q7-3K9-FX) the Host displays prominently on its idle / join screen alongside the existing LAN QR. Clients type the code on the discover screen instead of (or in addition to) picking a Host from the Bonjour list. Remotes do the same on their pairing screen.
The code resolves through the relay to the Host's WebSocket endpoint; from that point on the join + live-play message families are bit-identical to the LAN path. Only the transport — direct WebSocket vs relay-tunnelled WebSocket — differs. This is deliberate: a v1 Client / Host / Remote that's been written against the LAN message envelopes runs over the relay with no protocol change.
Concrete relay design — code allocation, lease + expiry, NAT traversal fallback, billing / abuse controls — is the work tracked in Open Questions #3. UI surfaces for the code-entry flow are tracked in Client surfaces, Host surfaces, and Remote surfaces.
Slide and Object-Type Architecture
The atomic unit a quiz is built from is the slide. A slide owns two canvases (Host and Client) and an ordered list of elements placed on each. Elements are instances of object types.
An object type is a self-contained pluggable module that contributes:
- Schema — a JSON Schema definition under
schemas/object-types/<type-id>/that codegen produces as a C# type incom.quiz.corefor the Unity apps and as a TypeScript interface +ajvvalidator in the Angular Designer. Each type carries a declared schema version and a JSON serialisation contract. - Designer editor surface — an Angular standalone component (with a TypeScript view-model) that lets the author edit an element's properties in the Designer's properties inspector. The pixel-accurate render comes from the Host runtime surface (#3) running in the embedded
Quiz.PreviewUnity WebGL build via the in-WebView JS bridge (see Designer Shell). - Host runtime surface — a UGUI prefab + C# behaviour that renders and animates the element on the Host canvas during live play.
- Client runtime surface — a UGUI prefab + C# behaviour that renders the element on the Client canvas, including any input handling.
- Optional protocol extension — typed message envelopes the object type sends or receives between Host and Client (e.g. an answer submission, a buzzer press). Extensions register on the shared message envelope; the core protocol does not need to change to add a new object type.
Each object type has a globally unique, namespaced type id (e.g. core.text, core.image, core.multiple-choice-input, music.spotify-clip-player) and a schema version. Quiz manifests declare the type ids and versions they use; apps resolve them against a built-in registry compiled into each app and refuse a package they cannot fully resolve. The manifest declaration is part of the Quiz Package Format.
In v1, packages contain no runtime code — every object type a quiz uses must already be present as a built-in in the app's registry. Adding a new object type means adding a new self-contained module to the apps' source tree (it registers itself in the registry on startup) and shipping a new app build; the core orchestration, slide-rendering, and editor code do not change. Bundle-supplied object types — runtime modules carried inside a .quiz — are deferred to a stretch goal alongside cloud-backed authoring (see Open Questions).
Built-in object-type catalogue
The platform ships these built-in object types. Phase columns show when each lands — see Phases. Eighteen types ship across the v1 phases (MVP, Alpha, Beta); a further one is tracked in Stretch as a live-quiz format that earns its own type rather than being expressed as configuration on existing types.
| Type id | Phase | Purpose | Typical placement |
|---|---|---|---|
core.text |
MVP | Rendered text block. | Either canvas. |
core.multiple-choice-input |
MVP | Tappable answer options; first/only selection submitted. Beta extends the config with an optional elimination schedule (incorrect options vanish on a timeline configured by the author — e.g. start with 4 options, drop to 2 over the question duration) and an optional speed-bonus curve (more points for earlier correct submissions). Both fields default off and surface in the right-pane inspector. | Client canvas. |
core.free-text-input |
MVP | Text-entry answer field. | Client canvas. |
core.numeric-input |
MVP | Closest-wins scoring instead of exact-match; numeric validation. Essential for estimation rounds and tiebreakers. | Client canvas. |
core.true-false-input |
MVP | Binary ✓ / ✗ answer. Visually distinct from multiple-choice (two large tap targets, no letters). Cheap to ship alongside the MVP MCQ work since the answer-submit plumbing is the same. | Client canvas. |
core.timer |
MVP | Countdown / elapsed timer. Default behaviour: lock Client input on time-up. Author-configurable per slide: lock-or-not, late-submission scoring rule (none / fixed penalty / per-second decay), and whether the quizmaster can manually extend or skip. The Host is authoritative on time — see Networking. | Either canvas. |
core.leaderboard |
MVP | Per-team standings. Trigger-driven reveal: the author places the leaderboard wherever in the slide order they want it to appear, configures its reveal trigger (on slide entry / on quizmaster trigger / after delay), and optionally configures a reveal animation (full table / one row at a time / bottom-up). The leaderboard is not Host app chrome that appears automatically — it's an explicitly placed element under author and quizmaster control. | Either canvas. |
core.image |
Alpha | Static image from the package's resources/images/. |
Either canvas. |
core.audio-clip |
Alpha | Playable audio clip from resources/audio/. |
Host canvas. |
core.video |
Alpha | Video clip from resources/video/. |
Host canvas. |
core.drawing-input |
Alpha | Finger drawing capture (touch-only in v1); can mirror to Host. | Client canvas (with optional Host mirror). |
core.buzzer-input |
Alpha | Buzzer with first-press semantics across clients. When a team wins the press, the Host plays that team's chosen buzzer jingle (F-HO-27) — a clip the team picked at join from the quiz's bundled set (F-CL-15, F-DE-28). The element itself owns only the press-race protocol + on-canvas visual; jingle resolution happens in the Host's team-identity registry, not in the element. | Client canvas. |
core.ranking-input |
Alpha | Drag-to-reorder UX. Items are text, image, or both. Scoring is author-configurable: all-or-nothing for the full order, or partial-credit per item-in-correct-position. | Client canvas. |
core.categories-input |
Beta | Multi-line free-text. "Name 5 X." Each line scored independently. | Client canvas. |
core.mini-game |
Beta | Embeds a mini-game. The mini-game framework itself is a separate build-plan deliverable. | Client canvas (typically). |
core.match-pairs-input |
Beta | Drag/connect pairing UX. Left and right columns of items (text, image, or both); the team connects each left item to its right-side match. Multi-pair scoring — each correct pairing scores. | Client canvas. |
core.image-reveal-input |
Beta | Image obscured by a configurable shader filter (pixelate / blur / mosaic — author-picked) that gradually clears over the question duration. Multiple-choice submission underneath; earlier-correct = more points, governed by a configurable speed-bonus curve. Inspector configures the reveal curve (linear / ease-out / stepped), filter type, filter start/end intensity, and the points-vs-time function. | Host canvas (image) + Client canvas (answer input). |
core.word-scramble-buzzer |
Beta | A target word's letters render scrambled with continuous tile motion (drift / swap) on the Host canvas; teams race to unscramble and submit via a buzzer-style first-press on the Client. First correct team scores. Inspector configures the target word, scramble difficulty (animation intensity + hint cadence), wrong-answer penalty, and per-team buzzer cooldown after a wrong attempt. | Host canvas (animated tiles) + Client canvas (buzzer-plus-text submit). |
core.hotspot-input |
Stretch | Tap on image coordinates. "Where on the map is X?" / anatomy / geography. | Client canvas. |
Mini-game built-ins (Beta)
core.mini-game is a container element; the actual mini-games are entries in a separate mini-game registry that the element resolves by id. Three mini-games ship as built-ins in Beta. Each is fully inspector-configurable on the slide it's placed on — defaults below are starting points, not hard-coded behaviour.
| Mini-game id | Format | Inspector-configurable |
|---|---|---|
mini-game.internal-clock |
The Host displays a running countdown; each team presses on their Client to stop their own clock. No on-screen number — the team must judge the elapsed time. Closer to the target = more points; going over the target scores nothing. | Target time (default 20s), points-vs-error curve (e.g. 100 at 0s error, 0 at ±X s error), overshoot policy (default no score, optional fixed penalty), per-team retry rule (default single press). |
mini-game.team-shoot |
Host flashes a single team name; that team races a configurable opponent team (or the next-fastest team in the room) to tap their Client. First valid press wins; a press from any team whose name was not shown loses configurable points. | Match-up rule (named opponent / next-finisher / all-others-penalised), points for winning team, points for losing team, penalty for wrong-team presses (per-team configurable), reaction window before any press counts (default 0 ms). |
mini-game.spin-wheel-modifier |
Triggers after a parent question resolves. The Host renders a spinning wheel whose segments are point-modifier values (positive or negative). Lands on a segment; the modifier applies to the team(s) the inspector targets. Used as a scoring flourish on top of an existing answer, not as a standalone question. | Segment list (label + ± value per slot), spin duration, target rule (apply to winning team / all correct teams / all teams), parent-question linkage (which slide element provides the trigger), allow-zero-segments flag. |
The mini-game registry resolution and the core.mini-game element's lifecycle hand-off (entry / scoring publication / exit) are defined in Object-Type Contract. Adding a fourth mini-game means adding a registry entry — the core.mini-game element itself does not change.
Leaderboard and timer are object types (not Host/Client app chrome) so the author has full creative control over when and where they appear. Reveal triggers are a general element capability, not specific to the leaderboard: any element can declare a reveal trigger (on slide entry / on quizmaster trigger / after delay) and a reveal animation. The slide schema reserves a reveal field on every element for this — see Slide Schema — Reveal for the field shape and Object-Type Contract for interfaces, lifecycle, serialisation rules, and version negotiation.
Shared element properties
Every placed element — regardless of object type — carries the same shared property block in addition to its object-type-specific fields. These are the fields a generic editor (move / lock / reveal) needs to operate on any element, and the Designer's right-pane Properties tab renders this block once at the top followed by the object-type-specific editor below.
| Property | Type | Description |
|---|---|---|
id |
string (UUID) | Stable per-element identifier; survives reorder and clipboard ops. Used by reveal-trigger commands and protocol messages targeting a specific element. |
name |
string (optional) | Author-facing label. Defaults to the object type's display name; surfaces in the slide outline, error messages, and reveal-target picker. |
type |
string | Object-type id (e.g. core.text, core.multiple-choice-input). Bound at insertion; not re-editable. |
canvas |
host | client |
Which canvas the element lives on. An element can be duplicated to the other canvas (independent instance), but a single element is on exactly one. |
transform.host |
{ x, y, w, h, rotation, z-order } |
Host-canvas placement — absolute coordinates against the fixed 1920×1080 virtual canvas. Present only when canvas == host. |
transform.client |
{ anchors, region \| stack-slot, z-order } |
Client-canvas placement — responsive layout. Anchors and region pin the element to one of the slide's declared regions / stacks. Present only when canvas == client. |
visibility |
always | triggered |
always = visible from slide-entry. triggered = hidden until its reveal.trigger fires. |
reveal.trigger |
on-entry | on-cue | after-delay |
When this element appears (or animates in) on the canvas during play. on-cue is fired by the quizmaster from the Host operator window or paired Remote (F-HO-25, F-RE-9). |
reveal.delay-ms |
int | Used only when reveal.trigger == after-delay. Milliseconds from slide-entry. |
reveal.animation |
string | Animation key — fade, slide-in-left, pop, etc. Animations are part of the design language; the catalogue lands in Beta. MVP supports none and fade. |
lock |
bool | Designer-only flag. When true, the canvas selection chrome prevents drag / resize / rotate; inspector edits remain available. Lands in Beta. |
notes |
string (optional) | Internal author notes about the element (not the slide-level host-notes per F-DE-19). Lands in Beta. |
The shared block is serialised once per element; per-object-type schemas extend it. The full field-level schema for the shared block and every per-type objectTypeData payload lives in Slide Schema (resolves Open Questions #2). The right-pane inspector renders the shared block in a fixed order (Identity → Transform → Reveal → Lock) followed by the object-type editor.
For the per-app UI inventory of where these properties surface in the Designer (inspector sections, context-menu items, lock badge, reveal cue affordance) see Designer surfaces — §8 Right pane.
Object-Type Contract
The C# interfaces every object type implements, the lifecycle each type goes through inside each app, and the rules that govern version negotiation between an authored .quiz and an installed app's built-in registry. This page is the precise contract that backs the narrative in Object-Type Architecture and the JSON field shapes in Slide Schema.
The contract lives in com.quiz.core (data layer for the Unity apps — schema, validation, version negotiation, protocol envelopes; types regenerated from schemas/) and com.quiz.runtime (Unity layer — surface adapters, prefab binding). The Designer-side surface contract is implemented in Quiz.Designer/src/app/object-types/ as Angular standalone components implementing the DesignerEditor<TData> TypeScript interface; the matching TData TypeScript type is codegen'd from the same JSON Schema as the C# TData record consumed by the Unity apps.
Surface contributions
Every type contributes the five surfaces enumerated in Object-Type Architecture — schema, Designer editor, Host runtime, Client runtime, optional protocol extension. The interfaces below pin the C# contract for each.
IObjectType<TData>
The root interface. One implementation per object-type id, registered at app startup into the per-app registry. The type parameter TData is the C# record (POCO) that round-trips with element.objectTypeData per Slide Schema.
namespace Quiz.Core.ObjectTypes;
public interface IObjectType<TData> : IObjectType where TData : class, IObjectTypeData
{
new TData DefaultData();
new ValidationResult Validate(TData data, ValidationContext ctx);
new TData Migrate(TData data, int fromSchemaVersion);
}
public interface IObjectType
{
string Id { get; } // e.g. "core.text"
int SchemaVersion { get; } // current built-in version
string DisplayName { get; } // shown in palette
string Description { get; } // shown in palette tooltip
ObjectTypeCapabilities Capabilities { get; }
// Non-generic surface — used by registry code that doesn't know TData.
IObjectTypeData DefaultData();
ValidationResult Validate(IObjectTypeData data, ValidationContext ctx);
IObjectTypeData Migrate(IObjectTypeData data, int fromSchemaVersion);
}
public record ObjectTypeCapabilities
{
public bool AllowedOnHostCanvas { get; init; }
public bool AllowedOnClientCanvas { get; init; }
public bool AcceptsInput { get; init; } // true => Client-side input
public bool EmitsProtocolMessages { get; init; } // true => has IProtocolExtension
public bool IsStateful { get; init; } // true => participates in crash-recovery snapshot
}
public interface IObjectTypeData
{
int SchemaVersion { get; }
}
Notes:
DefaultData()is what the Designer inserts on "add new element". The returned record must passValidateagainst an empty context.Validatereturns aValidationResultwithErrors(rejecting) andWarnings(non-blocking). Validation runs on Designer save, Host load, and Client receive.Migrateis the per-type migration step from any olderSchemaVersionup to the current built-in. Per-type migrations chain —Migrate(v3 → vCurrent)may internally callMigrate(v3 → v4)thenv4 → vCurrent. The contract requiresMigrate(currentVersion)to return the input unchanged.IsStatefultypes are queried for aStateSnapshotduring crash-recovery snapshotting — see Live Play Protocol — Crash recovery.
DesignerEditor<TData> — Designer surface
Designer side. An Angular standalone component that renders the properties inspector for one element. Authored in TypeScript in the Angular workspace; runs inside the Tauri desktop shell today and stays portable to the eventual Web Designer Stretch because it consumes the PlatformAdapter for shell-specific capabilities.
// Quiz.Designer/src/app/object-types/designer-editor.ts
import { Type } from '@angular/core';
import { ObjectTypeData } from '../generated/object-types';
export interface DesignerEditor<TData extends ObjectTypeData> {
readonly typeId: string; // matches IObjectType.Id, e.g. "core.text"
readonly componentType: Type<unknown>; // standalone Angular component, e.g. MultipleChoiceEditorComponent
readonly paletteIconSrc: string; // SVG asset path bundled under src/assets/palette/
readonly paletteGroup: 'Content' | 'Input' | 'Display' | 'Game';
}
The Angular component itself binds to a view-model service that exposes TData as a reactive signal. Every property edit goes through the Designer's CommandDispatcher Angular service (see Designer Shell — Undo / redo granularity) so it is undoable; the component never mutates the model directly.
TData is generated from the same JSON Schema as the C# TData record on the Unity side, so the on-the-wire shape stays in lockstep. Runtime validation in the Designer uses the ajv-compiled validator codegen'd alongside the type; the same schema fixture set asserts the C# and TS validators agree on every test input — see Repository Layout — Cross-language contract.
IHostRuntime<TData> — Host surface
Unity-aware. A UGUI prefab + MonoBehaviour (or equivalent). Lifecycle hooks invoked by the Host's slide runner.
namespace Quiz.Runtime.ObjectTypes.Host;
public interface IHostRuntime<TData> where TData : class, IObjectTypeData
{
string PrefabAddress { get; } // Addressable address to the host prefab
void OnSlideEnter(IHostElementContext ctx, TData data);
void OnReveal(IHostElementContext ctx, TData data); // called when reveal.trigger fires
void OnAdvance(IHostElementContext ctx, TData data); // called on slide leave
void OnTick(IHostElementContext ctx, TData data, in TickInfo tick);
void OnProtocolMessage(IHostElementContext ctx, TData data, IObjectMessage msg);
}
public interface IHostElementContext
{
GameObject Root { get; }
Element Element { get; }
Slide Slide { get; }
ISessionState Session { get; }
IHostMessageSink Messages { get; } // outbound protocol messages
IAudioBus Audio { get; } // shared audio bus
}
PrefabAddress resolves via Unity Addressables. The prefab is loaded once per slide load, instantiated under the Host canvas root, and its components are bound by the runtime adapter. OnTick fires at the Host's slide-tick cadence (≥ 60 Hz).
IClientRuntime<TData> — Client surface
Mirror of IHostRuntime with Client-specific concerns (input handling, lock state, region-bound layout).
namespace Quiz.Runtime.ObjectTypes.Client;
public interface IClientRuntime<TData> where TData : class, IObjectTypeData
{
string PrefabAddress { get; }
void OnSlideEnter(IClientElementContext ctx, TData data);
void OnReveal(IClientElementContext ctx, TData data);
void OnLock(IClientElementContext ctx, TData data);
void OnUnlock(IClientElementContext ctx, TData data);
void OnAdvance(IClientElementContext ctx, TData data);
void OnProtocolMessage(IClientElementContext ctx, TData data, IObjectMessage msg);
}
public interface IClientElementContext
{
GameObject Root { get; }
Element Element { get; }
Region BoundRegion { get; }
ISessionState Session { get; }
IClientMessageSink Messages { get; }
bool IsLocked { get; }
}
OnLock / OnUnlock correspond to the Host's authoritative timer lock — the Client must stop accepting input on OnLock and may resume on OnUnlock.
IProtocolExtension<TMessage> — optional protocol extension
Object types that exchange wire messages (e.g. an answer-submit, a buzzer-press, a drawing-stroke) register a protocol extension. The Host and Client serialise the message via the shared envelope (see Live Play Protocol — Object-message extensions).
namespace Quiz.Core.ObjectTypes;
public interface IProtocolExtension<TMessage> : IProtocolExtension where TMessage : class, IObjectMessage
{
new TMessage Deserialize(ReadOnlySpan<byte> json);
new ReadOnlyMemory<byte> Serialize(TMessage msg);
}
public interface IProtocolExtension
{
string ObjectTypeId { get; } // matches IObjectType.Id
string MessageNamespace { get; } // e.g. "core.mcq" — used in envelope routing
int SchemaVersion { get; } // mirrored from IObjectType.SchemaVersion
Type MessageType { get; } // the C# type implementing IObjectMessage
IObjectMessage Deserialize(ReadOnlySpan<byte> json);
ReadOnlyMemory<byte> Serialize(IObjectMessage msg);
}
public interface IObjectMessage
{
string Kind { get; } // e.g. "submit" | "press" | "stroke"
}
Multiple message kinds can share one extension — Kind discriminates inside the envelope's payload.
IObjectTypeRegistry
The per-app registry. One instance per app, populated at startup.
namespace Quiz.Core.ObjectTypes;
public interface IObjectTypeRegistry
{
IObjectType? Get(string id);
bool TryGet(string id, out IObjectType? type);
IEnumerable<IObjectType> All();
VersionNegotiationResult Negotiate(string id, int requestedVersion);
void Register(IObjectType type);
}
public enum VersionNegotiationResult
{
Ok, // built-in matches or is forward-compatible
UpgradedOnLoad, // app is newer than the package; in-memory migration applies
Incompatible, // refuse the package
UnknownType // refuse the package
}
The registry is immutable after startup in v1 — there is no runtime registration / unregistration. Each app's Program.cs (or equivalent bootstrap) calls Register(...) once per built-in type before the first slide load.
The Designer maintains its own equivalent ObjectTypeRegistry Angular service, populated with the same type metadata and the DesignerEditor<TData> descriptor paired with each entry.
Lifecycle
App startup
- App's bootstrap loads its
IObjectTypeRegistryimplementation from DI. - Each built-in type is registered (
Register(new TextObjectType()), etc.). Registration order is non-significant but must be stable for reproducible test runs. - Once registration is sealed, the registry's
Registermethod becomes a no-op (returnsfalse) — no late registrations.
Package load
- Host (or Client) deserialises
manifest.json. - For each
manifest.objectTypes[]entry, callregistry.Negotiate(id, requestedVersion). If any returnsIncompatibleorUnknownTypethe package is refused and the user is shown the offending type id. - For each slide, deserialise
objectTypeDatainto the concreteTDatafor its type viaQuiz.Core.JsonContext. The type-specificValidateruns after deserialisation. - Slides are now in memory and addressable.
Slide enter
- Runtime adapter instantiates the prefab for each visible element (
reveal.trigger == onEntryorvisibility == always). Triggered elements are instantiated but kept hidden until their cue fires. OnSlideEnteris called on each element's runtime in z-order.
Reveal
- For
trigger == afterDelay: a coroutine in the runtime adapter waitsreveal.delayMsand then fires. - For
trigger == onCue: the runtime listens on the control message family for acueRevealenvelope keyed byelementId(sent by the quizmaster from the Host operator window or paired Remote). OnRevealis called; the runtime adapter plays thereveal.animationfrom DOTween ifanimation != none.
Tick (Host only)
OnTick fires on every Host frame (≥ 60 Hz). The runtime adapter computes a TickInfo:
public readonly record struct TickInfo(
long SlideElapsedMs,
long SlideRemainingMs,
bool IsLocked);
Clients do not receive ticks — they reconcile against the Host's periodic time-remaining message instead (see Live Play Protocol — Timer authority).
Slide advance
OnAdvance is called on every element on the leaving slide. Stateful types are queried for a snapshot before the prefab is destroyed.
Version negotiation
IObjectTypeRegistry.Negotiate(id, requestedVersion) resolves as follows:
| Built-in version | Requested version | Result | Reason |
|---|---|---|---|
N (any) |
unknown id | UnknownType |
App doesn't ship this type. |
N |
N |
Ok |
Exact match. |
N |
< N |
UpgradedOnLoad |
App is newer; Migrate(requested) is called per-element on load, then the in-memory schema is N. |
N |
> N |
Incompatible |
App is older than the authored package. Refuse — user updates the app or re-saves from a Designer with the matching version. |
Schema versions are monotonic integers per object-type id. There is no semver. A bump means a schema change (added / removed / restructured field). Bumps are accompanied by a Migrate step covering the bump path; the migration test suite asserts Migrate(v_n_minus_1) ≡ Validate(currentSchema) for every published bump.
The UpgradedOnLoad path does not rewrite the on-disk .quiz — the migration is in-memory only. Re-saving from a Designer running the newer version stamps the new version on disk.
Statefulness — crash-recovery payload
Each IObjectType<TData> declares whether its on-canvas instances own live-session state that the Host's crash-recovery snapshot must capture. Stateless types (static text, image) skip the snapshot path entirely. Stateful types (timer countdown remaining, buzzer first-press race winner, accumulated per-team answers, leaderboard reveal index) expose a small serialiser the Host reads each time a snapshot is written.
public interface IObjectType<TData>
{
// ... id, schemaVersion, schema accessor (unchanged from the IObjectType section above) ...
/// <summary>True when on-canvas instances of this type own live-session
/// state the Host must persist for crash-recovery rejoin. False for pure
/// presentation types.</summary>
bool IsStateful { get; }
/// <summary>Snapshot the live state of one element instance. Called on
/// the Host main thread; must return within 1 ms (budget shared with
/// other elements in the same snapshot pass). Return null for stateless
/// instances even on a stateful type (the runtime may opt out per-instance).</summary>
IElementStateSnapshot? CaptureState(IHostElementContext ctx);
/// <summary>Restore element state from a snapshot captured by an earlier
/// process. Called on the Host main thread during recovery, before the
/// element receives its first tick. The snapshot is the exact payload
/// returned by `CaptureState`; deserialisation happens inside the
/// element implementation, not in `com.quiz.core`.</summary>
void RestoreState(IHostElementContext ctx, IElementStateSnapshot snapshot);
}
Snapshot payloads ride the same DTOs the live message envelopes use, so deserialise == replay. The Host's snapshot writer (see Networking — Crash recovery) walks the current slide's elements, calls CaptureState on each stateful element, and serialises the union into the on-disk JSON snapshot. On relaunch the Host calls RestoreState per element after the slide loads, before any tick fires.
Rules:
- Default is stateless. Authors of new built-in types opt in to statefulness explicitly. The compiler does not enforce this — the registry validates on app startup that every type with
IsStateful = truehas a non-throwingCaptureState/RestoreStatepair. - Snapshot size is bounded. Each
IElementStateSnapshotserialises to ≤ 8 KB. Types whose state genuinely exceeds 8 KB (rare; large drawing canvases are the only candidate) must compress or stream — same wire-budget rationale as the team-photo cap. - No cross-element references. A stateful element's snapshot must be self-contained — it cannot point at another element's state by id. Snapshots are restored in arbitrary order.
- Protocol extensions are stateless on the wire. Stateful behaviour lives entirely behind
IObjectType;IProtocolExtensionmessages are transient and never persist.
The first stateful built-in is core.timer (Alpha). MVP built-ins (core.text, core.multiple-choice-input, core.free-text-input, core.numeric-input, core.true-false-input, core.leaderboard) all ship stateless until Alpha; core.leaderboard's reveal-index becomes stateful when triggered reveals land.
Built-in registry composition per app
| App | Registered types | Notes |
|---|---|---|
| Designer | All built-ins paired with their DesignerEditor<TData>. |
Angular standalone components inside the Tauri WebView; Host/Client runtime surfaces are referenced for capability metadata only (codegen'd from the same schema). |
| Host | All built-ins with their IHostRuntime<TData> and optional IProtocolExtension. |
Owns the audience-facing render. |
| Client | All built-ins with their IClientRuntime<TData> and optional IProtocolExtension. |
Filters at load — types whose Capabilities.AllowedOnClientCanvas == false are still registered so the Client can resolve manifests authored with Host-only types on Host-only canvases; the Client just never instantiates them. |
| Quiz.Preview | Same as Host (for authoring preview parity). | WebGL build; no networking. |
| Remote | Empty registry; the Remote operates on envelope-level mirrors, not elements. | The Remote's host-mirror is a scaled-down pixel stream + telemetry. |
Adding a new built-in object type — checklist
For internal contributors / agents:
- Author the type's JSON Schema under
schemas/object-types/{type-id}/{type-id}.schema.jsonand add it toschemas/codegen.config.json. - Run
npm run schema:gen(Angular side) and the C# codegen target (Unity side) to produceTDatatypes on both sides. - Implement
IObjectType<TData>incom.quiz.core/ObjectTypes/Core/{TypeId}/{TypeId}.cs(handwritten — wraps the generatedTData). - Add
objectTypeDataschema to Slide Schema — Per-Type Schemas — this page is the canonical home for the human-readable form. - Add the type to the built-in catalogue in Object-Type Architecture.
- Implement the Designer editor component +
DesignerEditor<TData>descriptor inQuiz.Designer/src/app/object-types/{type-id}/. - Implement
IHostRuntime<TData>incom.quiz.runtime/Host/{TypeId}/. - Implement
IClientRuntime<TData>incom.quiz.runtime/Client/{TypeId}/. - If the type exchanges wire messages, author the protocol message schemas under
schemas/live-play/object-types/{type-id}/and implementIProtocolExtension<TMessage>incom.quiz.core/ObjectTypes/{TypeId}/Protocol/. - Register the type in each app's bootstrap (Unity registries + Angular
ObjectTypeRegistryservice). - Add round-trip tests for
TData(serialisation, validation, migration from prior versions) on both Unity (NUnit) and Designer (Jest) sides, fed by shared fixtures inschemas/fixtures/object-types/{type-id}/. - Add end-to-end test exercising a one-slide quiz containing only the new type — Playwright spec on the Designer side; play-mode test on the Host / Client side.
Adding a new type never requires touching the slide runner, schema validator, or message-envelope code — that is the contract the v1 plugin model commits to.
Cross-links
- Object-Type Architecture — built-in catalogue + plugin-model narrative.
- Slide Schema — per-type
objectTypeDatashapes. - Live Play Protocol — how protocol extensions ride the message envelope.
- Designer Shell — Undo / redo granularity — the command/undo pipeline
DesignerEditorplugs into. - Open Questions #1 — bundle-supplied (third-party) object types, deferred to Stretch.
Slide Schema
The field-level JSON schema for everything inside a .quiz package: the manifest, slides, canvases, elements, transforms, reveal, timing, and scoring. This is the canonical contract — the C# types in com.quiz.core are generated from / reflect this page, and <a href="#quiz-package-format">← Quiz Package Format</a> describes how these JSON files are bundled on disk.
Resolves Open Questions #2. Per-object-type schemas extending the shared element block live alongside their type definitions (see Object-Type Contract). WebSocket envelope shapes are not in this page — they live in Live Play Protocol.
Conventions
- All JSON uses UTF-8, 2-space indent, LF line endings, camelCase field names. The Designer's writer enforces these on save.
- All identifiers (
quizId,slideId,elementId,regionId) are UUIDv4 strings unless stated otherwise. The Designer mints them; they are stable across save/load/transfer. - Timestamps are ISO-8601 UTC strings (
2026-05-13T14:32:11Z). - Durations are integer milliseconds unless suffixed
_s(seconds, for human-authored fields). - All coordinates on the Host canvas are floats measured against the fixed
1920 × 1080virtual canvas. The runtime scales to fit the connected display. - All sizes / durations / lists not marked
optionalare required; the validator rejects packages with missing required fields. - An
objectTypeDatafield on each element carries the type-specific payload — its shape is owned by the object type (see Object-Type Contract) and not pinned here.
manifest.json
The top-level package manifest. Exactly one per .quiz. Fields:
| Field | Type | Required | Description |
|---|---|---|---|
schemaVersion |
integer | ✓ | The schema version this package was authored against. Current value: 1. The Host refuses packages with a schemaVersion it does not know — see Versioning. |
quizId |
UUIDv4 | ✓ | Stable identifier for the quiz across saves and transfers. Survives rename. |
title |
string | ✓ | Author-facing title. 1–120 chars. |
description |
string | optional | Free-form description. 0–2000 chars. |
tags |
string[] | optional | Author-defined tags. Each tag 1–30 chars, kebab-case recommended. Max 16 tags. |
author |
object | optional | { "displayName": string, "accountId": string? }. accountId is only populated when the cloud authoring stretch goal is active. |
theme |
enum | ✓ | One of dark (default), light, plus brand presets registered by the Designer. MVP and Alpha render against dark regardless of declaration; Host / Client / Remote honour this from Beta. |
createdAt |
ISO-8601 UTC | ✓ | Stamped on quiz create. Never edited. |
updatedAt |
ISO-8601 UTC | ✓ | Stamped on every save. |
slideOrder |
UUIDv4[] | ✓ | Ordered list of slide ids. The runtime traverses slides in this order; reorder = rewrite this array. Min 1, max 500. Each id must match a file under slides/<slideId>.json. |
rounds |
object[] | optional | Round groupings — see Rounds. |
objectTypes |
object[] | ✓ | Declared object types — see Object-Type Declaration. |
buzzers |
object[] | optional | Bundled buzzer jingles — see Buzzers. Absent or empty = jingle picker hidden on Client. |
avatars |
object[] | optional | Bundled premade avatars — see Avatars. |
teamColours |
object[] | optional | Author-defined palette for team-picks-colour-at-join — see Team Colours. Beta only; absent through MVP/Alpha. |
Rounds
"rounds": [
{
"id": "1f...e7",
"title": "General Knowledge",
"scoring": { "pointsPerCorrect": 1, "lateSubmissionRule": "noPenalty" },
"slideRange": { "fromSlideId": "a1...b2", "toSlideId": "c3...d4" }
}
]
| Field | Type | Required | Description |
|---|---|---|---|
id |
UUIDv4 | ✓ | Stable round id. Survives reorder. |
title |
string | ✓ | Author-facing label. 1–60 chars. |
scoring |
object | optional | Round-level defaults — see Scoring. Per-slide scoring overrides this. |
slideRange |
object | ✓ | { fromSlideId, toSlideId } — both inclusive. Range must be contiguous in slideOrder; the validator rejects overlapping or out-of-order rounds. |
Object-Type Declaration
"objectTypes": [
{ "id": "core.text", "schemaVersion": 1 },
{ "id": "core.multiple-choice-input", "schemaVersion": 1 },
{ "id": "core.timer", "schemaVersion": 1 }
]
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | ✓ | Namespaced type id. Regex: ^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$. Reserved namespace: core.* (built-ins). |
schemaVersion |
integer | ✓ | Required version of the type's schema. The Host / Client refuse on missing or version-incompatible — see Object-Type Contract — Versioning. |
The declared list is the union of types used across every slide. The Designer regenerates it on every save.
Buzzers and Avatars
"buzzers": [
{ "id": "buz-001", "name": "Klaxon", "file": "resources/audio/buzzers/klaxon.mp3", "durationMs": 1200 }
],
"avatars": [
{ "id": "av-001", "name": "Fox", "file": "resources/avatars/fox.png" }
]
Buzzers: id is author-stable (kebab-case), name is shown to the Client at join, file is a path inside the archive relative to root, durationMs is measured at bundle time and used by the Client preview UI. Format: MP3 / OGG / WAV; mono or stereo; ≤ 8 s hard cap.
Avatars: file is PNG or JPEG, square, ≥ 256 × 256 px; the Host renders them on leaderboard rows.
Team Colours
Beta scope. Schema:
"teamColours": [
{ "id": "tc-red", "name": "Crimson", "swatch": "#D7263D" },
{ "id": "tc-blue", "name": "Cobalt", "swatch": "#1B4FA0" }
]
swatch is a CSS hex (#RRGGBB). The Client offers this palette at join (F-CL-12).
Slide files (slides/<slideId>.json)
One file per slide. Filename is the slide's UUID.
{
"id": "a1...b2",
"schemaVersion": 1,
"title": "Capital of France?",
"roundId": "1f...e7",
"timing": { "durationMs": 30000, "lockOnTimeUp": true, "lateSubmissionRule": "noPenalty", "allowQuizmasterOverride": true },
"scoring": { "pointsPerCorrect": 1, "incorrectPenalty": 0 },
"hostNotes": "**Answer:** Paris. If teams say *London*, that's a classic dad-joke setup — let them have the laugh.",
"hostCanvas": { "regions": [], "elements": [ ...elementBlocks... ] },
"clientCanvas": { "regions": [ ...regionBlocks... ], "elements": [ ...elementBlocks... ] }
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
UUIDv4 | ✓ | Matches the filename and the entry in manifest.slideOrder. |
schemaVersion |
integer | ✓ | Per-slide schema version. Same value as manifest.schemaVersion in practice; carried per-slide so a future migration can rewrite slides one at a time. |
title |
string | ✓ | Author-facing label. Appears in slide list and Designer outliner. 1–120 chars. Never renders to audience. |
roundId |
UUIDv4 | optional | If set, must reference a rounds[].id whose slideRange covers this slide. |
timing |
object | ✓ | See Timing block. |
scoring |
object | optional | Per-slide scoring — see Scoring block. Overrides rounds[].scoring. Optional only if the round-level scoring fully covers the slide. |
hostNotes |
string | optional | Markdown. Rendered only on Remote + Host operator window during play, never on audience-facing surfaces. Max 4000 chars. |
hostCanvas |
object | ✓ | The TV-facing canvas — see Host canvas. |
clientCanvas |
object | ✓ | The phone-facing canvas — see Client canvas. |
Timing block
"timing": {
"durationMs": 30000,
"lockOnTimeUp": true,
"lateSubmissionRule": "noPenalty",
"lateSubmissionPenalty": null,
"allowQuizmasterOverride": true
}
| Field | Type | Required | Description |
|---|---|---|---|
durationMs |
integer | ✓ | Slide duration. 0 means untimed — no countdown, no lock. Else 1000–600000 (10 min cap). |
lockOnTimeUp |
bool | ✓ | If true, Host emits lock at time-up; Clients stop accepting input. Default true. |
lateSubmissionRule |
enum | ✓ | none (reject), noPenalty (accept and score normally), fixedPenalty, perSecondDecay. Default none. |
lateSubmissionPenalty |
object | null | conditional | Required if lateSubmissionRule is fixedPenalty ({ "points": int }) or perSecondDecay ({ "pointsPerSecond": float, "floor": int }, where floor is the minimum point value the answer can score down to). null otherwise. |
allowQuizmasterOverride |
bool | ✓ | If true, the operator + paired Remote can extend / skip / manually lock / manually unlock the timer. Default true. |
If durationMs is 0 (untimed), lockOnTimeUp, lateSubmissionRule, and lateSubmissionPenalty are ignored on load but must still be present (default values).
Scoring block
"scoring": {
"pointsPerCorrect": 1,
"incorrectPenalty": 0,
"partialCreditMode": "none",
"tiebreakerRule": "closestWins"
}
| Field | Type | Required | Description |
|---|---|---|---|
pointsPerCorrect |
integer | ✓ | Default 1. Range −100…100. Negative is legal (the "trick" round). |
incorrectPenalty |
integer | ✓ | Subtracted from team score on incorrect submit. Default 0. Range 0…100. |
partialCreditMode |
enum | ✓ | none (binary correct/incorrect), perItem (per-line / per-pair / per-position scoring on multi-item types). Default none. |
tiebreakerRule |
enum | optional | Only honoured by core.numeric-input. closestWins (default), closestAbsolute (absolute distance), closestRelative (distance as fraction of target). The validator emits a warning if the field is set on a slide without a core.numeric-input element. See core.numeric-input schema for the equal-distance tie rule. |
Host canvas
Fixed-grid layout. No regions — every element is absolutely positioned against the 1920 × 1080 virtual canvas.
"hostCanvas": {
"background": { "kind": "solid", "colour": "#0F0B1A" },
"elements": [ ...elementBlocks... ]
}
| Field | Type | Required | Description |
|---|---|---|---|
background |
object | optional | { "kind": "solid", "colour": "#RRGGBB" } or { "kind": "image", "resourceId": "img-..." } or absent (theme default — usually deep-purple). |
elements |
object[] | ✓ | Ordered list of placed elements (see Element block). Render order is the array order, then transform.host.zOrder resolves intra-array ties. |
Client canvas
Responsive layout. Elements anchor to named regions, defined per-slide. A region is a rectangle on a normalised 0..1 device-coord space, with rules for how it adapts across phone / tablet / portrait / landscape.
"clientCanvas": {
"background": { "kind": "themeDefault" },
"regions": [
{
"id": "rg-question",
"name": "Question",
"rect": { "x": 0.0, "y": 0.0, "w": 1.0, "h": 0.35 },
"minPx": { "w": 280, "h": 96 },
"maxPx": { "w": null, "h": 320 },
"stack": "vertical",
"padding": 16,
"gap": 12
},
{
"id": "rg-answers",
"rect": { "x": 0.0, "y": 0.35, "w": 1.0, "h": 0.65 },
"stack": "vertical",
"padding": 16,
"gap": 12
}
],
"elements": [ ...elementBlocks... ]
}
| Field | Type | Required | Description |
|---|---|---|---|
background |
object | optional | Same shape as Host canvas; default themeDefault. |
regions |
object[] | ✓ | At least one region. Each region's rect.{x,y,w,h} is 0..1 in screen space. Regions may not overlap; the validator rejects overlap. |
elements |
object[] | ✓ | Each element references a region by id via transform.client.regionId. |
Region fields:
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | ✓ | Kebab-case, slide-unique. Stable across reorder. |
name |
string | optional | Designer label; if absent, the inspector falls back to the id. |
rect |
{ x, y, w, h } |
✓ | 0..1 normalised coords. x + w ≤ 1, y + h ≤ 1. |
minPx, maxPx |
{ w, h }, nullable |
optional | Pixel clamps applied after the responsive layout pass; either dimension may be null (no clamp). |
stack |
enum | ✓ | vertical, horizontal, none (free positioning inside the rect via transform.client.offset). |
padding |
integer | optional | Inner padding in pixels. Default 12. |
gap |
integer | optional | Gap between stacked children. Default 8. |
Element block
Every element on either canvas. The shared block (below) is identical across object types; the object-type-specific payload lives in objectTypeData.
{
"id": "el-...",
"type": "core.multiple-choice-input",
"typeVersion": 1,
"name": "Q1 options",
"canvas": "client",
"transform": {
"client": {
"regionId": "rg-answers",
"stackSlot": null,
"offset": null,
"zOrder": 0
}
},
"visibility": "always",
"reveal": { "trigger": "onEntry", "delayMs": 0, "animation": "none" },
"lock": false,
"notes": "",
"objectTypeData": { ... }
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
UUIDv4 | ✓ | Stable per-element. Used by reveal-trigger commands and protocol messages. |
type |
string | ✓ | Object-type id. Bound at insertion; never re-edited. |
typeVersion |
integer | ✓ | Schema version this element was authored against. Resolved against the app's built-in registry on load. |
name |
string | optional | Author-facing label. Defaults to the object-type display name. |
canvas |
enum | ✓ | host or client. An element lives on exactly one. |
transform |
object | ✓ | See Transforms — exactly one of transform.host or transform.client is present, matching canvas. |
visibility |
enum | ✓ | always (rendered from slide entry) or triggered (hidden until reveal.trigger fires). Default always. |
reveal |
object | ✓ | Reveal block — see Reveal. Always present, even when visibility: always (in which case the trigger is informational). |
lock |
bool | ✓ | Designer-only. true prevents drag / resize / rotate on the canvas. Beta. Defaults false. |
notes |
string | optional | Author-internal notes about this element. Distinct from slide.hostNotes. Beta. Max 1000 chars. |
objectTypeData |
object | ✓ | Type-specific payload — see Per-Type Schemas. |
Transforms
transform.host — Host canvas
"host": { "x": 240, "y": 180, "w": 1440, "h": 240, "rotation": 0.0, "zOrder": 0 }
| Field | Type | Required | Description |
|---|---|---|---|
x, y |
float | ✓ | Top-left in 1920 × 1080 virtual coords. Range −1920…3840 / −1080…2160 (off-canvas placement is legal for animation entry points). |
w, h |
float | ✓ | Element size in virtual coords. Min 8 × 8; max 1920 × 1080. |
rotation |
float | ✓ | Degrees, clockwise from horizontal. Range −180…180. Default 0. |
zOrder |
integer | ✓ | Higher renders on top of lower within the slide. Range −1000…1000. Default 0. |
transform.client — Client canvas
"client": { "regionId": "rg-answers", "stackSlot": 2, "offset": null, "zOrder": 0 }
| Field | Type | Required | Description |
|---|---|---|---|
regionId |
string | ✓ | Must reference a clientCanvas.regions[].id on the same slide. |
stackSlot |
integer | null | conditional | Required when the region's stack is vertical or horizontal; the element's index within the stack (0-based). Stack-slot collisions are an authoring error — the Designer prevents them, the validator rejects them. |
offset |
{ x, y, w, h } | null |
conditional | Required when the region's stack is none; element rect inside the region in pixels relative to the region's top-left after padding. null otherwise. |
zOrder |
integer | ✓ | Within-region z. Range −1000…1000. Default 0. |
Reveal
"reveal": {
"trigger": "onCue",
"delayMs": 0,
"cueLabel": "Show answer",
"animation": "fade",
"animationDurationMs": 350,
"animationEasing": "easeOutCubic"
}
| Field | Type | Required | Description |
|---|---|---|---|
trigger |
enum | ✓ | onEntry (default — appears on slide entry), onCue (fired by quizmaster from Host operator window or paired Remote — see F-RE-9 / F-HO-24), afterDelay. |
delayMs |
integer | ✓ | Used only when trigger == afterDelay. Milliseconds from slide entry. Range 0…600000. |
cueLabel |
string | optional | Free-form label shown to the quizmaster on the cue picker. Used only when trigger == onCue. Default falls back to element.name. Max 60 chars. |
animation |
enum | ✓ | none, fade, slideInLeft, slideInRight, slideInTop, slideInBottom, pop, flipY, flipX. MVP supports none + fade; the full catalogue lands in Beta. |
animationDurationMs |
integer | ✓ | Range 0…3000. Default 350. 0 is legal and means "no tween, just snap". |
animationEasing |
enum | ✓ | DOTween easing curve name (linear, easeInQuad, easeOutQuad, easeInOutCubic, …). Default easeOutCubic. The full enum list is owned by com.quiz.shared-assets. |
When visibility == always, trigger is informational only — the element is on-canvas from slide entry. The Designer keeps the field editable because changing visibility to triggered should preserve the prior reveal choice.
Per-Type Schemas
Each object type extends the element block via the objectTypeData field. The shapes below cover the MVP cohort (core.text, core.multiple-choice-input, core.free-text-input, core.numeric-input, core.true-false-input, core.timer, core.leaderboard). Alpha + Beta types are documented as they land; the schema page is the canonical home for every type's objectTypeData shape (cross-referenced from Object-Type Architecture — Built-in object-type catalogue).
core.text
"objectTypeData": {
"schemaVersion": 1,
"text": "Capital of France?",
"style": {
"font": "display",
"sizePx": 72,
"weight": 700,
"colour": "#FFFFFF",
"align": "center",
"lineHeight": 1.2,
"letterSpacingEm": 0.0
}
}
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | ✓ | Markdown. Range 0…4000 chars. Empty string is legal (placeholder element). |
style.font |
enum | ✓ | display (Bebas Neue), body (Inter), mono (JetBrains Mono). Per Design Specification — Typography. |
style.sizePx |
float | ✓ | Range 8…400. Pixel size against the Host canvas's 1920×1080 baseline (auto-scaled with the canvas) or Client canvas's region pixel space. |
style.weight |
enum | ✓ | 400, 500, 600, 700, 900. Each maps to a packaged font weight. |
style.colour |
hex | ✓ | #RRGGBB. |
style.align |
enum | ✓ | left, center, right, justify. |
style.lineHeight |
float | ✓ | Multiplier. Default 1.2. Range 0.8…3.0. |
style.letterSpacingEm |
float | ✓ | Em units. Default 0. Range −0.2…0.5. |
core.multiple-choice-input
"objectTypeData": {
"schemaVersion": 1,
"options": [
{ "id": "opt-a", "label": "Paris", "isCorrect": true },
{ "id": "opt-b", "label": "London", "isCorrect": false },
{ "id": "opt-c", "label": "Berlin", "isCorrect": false },
{ "id": "opt-d", "label": "Madrid", "isCorrect": false }
],
"shuffleOptions": true,
"showLetters": true
}
| Field | Type | Required | Description |
|---|---|---|---|
options[].id |
string | ✓ | Stable per-option id. Kebab-case. |
options[].label |
string | ✓ | Option text. 1…200 chars. |
options[].isCorrect |
bool | ✓ | Exactly one option must have isCorrect: true. The validator rejects multi-correct in v1 (multi-correct is a Stretch extension). |
shuffleOptions |
bool | ✓ | If true, the Client renders options in a per-team randomised order. The submitted id is still authoritative on the Host. Default false. |
showLetters |
bool | ✓ | If true, prefixes options with A./B./C./D.. Default true. Range 2…6 options. |
core.free-text-input
"objectTypeData": {
"schemaVersion": 1,
"acceptedAnswers": ["Paris", "paris"],
"matchMode": "caseInsensitiveExact",
"trimWhitespace": true,
"maxLength": 80,
"placeholder": "Your answer…"
}
| Field | Type | Required | Description |
|---|---|---|---|
acceptedAnswers |
string[] | ✓ | Non-empty. At least one accepted answer. |
matchMode |
enum | ✓ | exact, caseInsensitiveExact, caseInsensitiveTrim, regex. Regex is opt-in; pattern strings are anchored implicitly (^…$). Default caseInsensitiveTrim. |
trimWhitespace |
bool | ✓ | Default true. Applied to submitted answers before matching. |
maxLength |
integer | ✓ | Client-side input cap. Default 80. Range 1…500. |
placeholder |
string | optional | Client-side input placeholder. Default empty. |
core.numeric-input
"objectTypeData": {
"schemaVersion": 1,
"targetValue": 100,
"tiebreakerRule": "closestAbsolute",
"equalDistanceRule": "firstSubmitWins",
"unit": "°C",
"decimalPlaces": 0,
"allowNegative": false,
"min": null,
"max": null
}
| Field | Type | Required | Description |
|---|---|---|---|
targetValue |
float | ✓ | The correct answer. |
tiebreakerRule |
enum | ✓ | How "closest" is measured. closestAbsolute (|x − target|, default), closestRelative (|x − target| / |target|, undefined when target == 0 — validator rejects), exactMatchOnly (no closest-wins; binary correct/incorrect). |
equalDistanceRule |
enum | ✓ | Resolution when two teams tie on closest-distance. firstSubmitWins (default — Host's receive-order tiebreak), splitPoints (each tied team gets floor(points/N)), allTied (each tied team gets the full point value). |
unit |
string | optional | Display unit (e.g. °C, m, %). Cosmetic only; the Client strips it on submit. |
decimalPlaces |
integer | ✓ | Range 0…6. Default 0. The Client clamps input to this precision; submissions exceeding it are server-rounded. |
allowNegative |
bool | ✓ | Default false. |
min, max |
float | null | optional | Inclusive bounds on the Client input. The runtime clamps; the Host re-validates and rejects out-of-bounds submits as malformed. |
core.true-false-input
"objectTypeData": {
"schemaVersion": 1,
"correctAnswer": true,
"trueLabel": "True",
"falseLabel": "False"
}
| Field | Type | Required | Description |
|---|---|---|---|
correctAnswer |
bool | ✓ | The correct response. |
trueLabel, falseLabel |
string | ✓ | Author-overridable button labels. Default True / False. 1…30 chars. |
core.timer
"objectTypeData": {
"schemaVersion": 1,
"mode": "countdown",
"display": {
"format": "mmss",
"showMilliseconds": false,
"warningAtMs": 5000,
"warningStyle": "pulse"
},
"boundToSlideTiming": true,
"overrideDurationMs": null
}
| Field | Type | Required | Description |
|---|---|---|---|
mode |
enum | ✓ | countdown (slide timer counts down from slide.timing.durationMs) or elapsed (counts up from slide entry). Default countdown. |
display.format |
enum | ✓ | mmss, ss, mmssms, ssms. |
display.showMilliseconds |
bool | ✓ | Default false. Redundant with format for clarity. |
display.warningAtMs |
integer | null | optional | When ≤ N ms remain (countdown only), the warning style activates. null disables. Default 5000. |
display.warningStyle |
enum | ✓ | none, pulse, colourShift, pulseAndColour. Default pulse. |
boundToSlideTiming |
bool | ✓ | If true, the timer's duration tracks slide.timing.durationMs and the timer is locked when the slide auto-locks. If false, the element's own overrideDurationMs applies and the timer is independent of slide-level locking. Default true. |
overrideDurationMs |
integer | null | conditional | Required when boundToSlideTiming == false. Range 1000…600000. |
Timer authority + tick cadence are protocol concerns — see Live Play Protocol — Timer authority.
core.leaderboard
"objectTypeData": {
"schemaVersion": 1,
"scope": "cumulative",
"ordering": "descendingPoints",
"rowLimit": 0,
"revealAnimation": {
"kind": "rowByRow",
"rowStaggerMs": 250,
"direction": "bottomUp"
},
"showAvatars": true,
"showScores": true,
"highlightTopN": 3
}
| Field | Type | Required | Description |
|---|---|---|---|
scope |
enum | ✓ | cumulative (whole-quiz scores) or roundOnly (scores accrued in the current round). Default cumulative. |
ordering |
enum | ✓ | descendingPoints (default), ascendingPoints (rare — "lowest score wins"). |
rowLimit |
integer | ✓ | 0 = show all teams. N > 0 = top-N only. Range 0…50. |
revealAnimation.kind |
enum | ✓ | none, fullTable, rowByRow. |
revealAnimation.rowStaggerMs |
integer | conditional | Required when kind == rowByRow. Ms between row reveals. Range 50…2000. Default 250. |
revealAnimation.direction |
enum | conditional | topDown or bottomUp. Required when kind == rowByRow. Default bottomUp. |
showAvatars |
bool | ✓ | Default true (Alpha onwards — see F-HO-26). MVP renders avatar placeholders. |
showScores |
bool | ✓ | Default true. |
highlightTopN |
integer | ✓ | Top-N rows get the "winner" visual treatment (gradient + glow). Range 0…10. Default 3. |
Alpha-cohort schemas
These types land in Alpha. Their objectTypeData payloads:
core.image
"objectTypeData": {
"schemaVersion": 1,
"resourceId": "img-9c1f...",
"fit": "contain",
"altText": "Paris skyline at dusk"
}
| Field | Type | Required | Description |
|---|---|---|---|
resourceId |
string | ✓ | SHA-256 of the bundled resource under resources/images/<resourceId>.<ext>. See Designer Shell — Library content-hash de-duplication. |
fit |
enum | ✓ | contain (default), cover, fill, none. Mirrors CSS object-fit. |
altText |
string | optional | For future accessibility work; rendered as Designer-only tooltip in v1. Max 200 chars. |
core.audio-clip
"objectTypeData": {
"schemaVersion": 1,
"resourceId": "aud-9c1f...",
"autoPlay": true,
"loop": false,
"volume": 1.0,
"showPlayer": false,
"fadeInMs": 0,
"fadeOutMs": 0
}
| Field | Type | Required | Description |
|---|---|---|---|
resourceId |
string | ✓ | SHA-256 of bundled MP3/OGG/WAV under resources/audio/slides/<resourceId>.<ext>. |
autoPlay |
bool | ✓ | If true, plays on slide entry (or on reveal trigger). Default true. |
loop |
bool | ✓ | Default false. |
volume |
float | ✓ | 0.0…1.0. Default 1.0. |
showPlayer |
bool | ✓ | If true, renders a transport-control UI on the Host canvas. Default false. |
fadeInMs, fadeOutMs |
integer | ✓ | Range 0…5000. Default 0. |
core.video
"objectTypeData": {
"schemaVersion": 1,
"resourceId": "vid-9c1f...",
"autoPlay": true,
"loop": false,
"muted": false,
"volume": 1.0,
"showPlayer": false,
"fit": "contain"
}
Field semantics mirror core.audio-clip + core.image.fit.
core.drawing-input
"objectTypeData": {
"schemaVersion": 1,
"canvasAspect": "16:9",
"strokeWidthPx": 6,
"strokeColour": "#FFFFFF",
"backgroundColour": "#1F1933",
"allowEraser": true,
"mirrorToHost": true,
"scoring": "manual"
}
| Field | Type | Required | Description |
|---|---|---|---|
canvasAspect |
enum | ✓ | 16:9, 4:3, 1:1, 9:16. Aspect ratio of the drawing surface. |
strokeWidthPx |
float | ✓ | Range 1…48. Default 6. |
strokeColour |
hex | ✓ | Default white. |
backgroundColour |
hex | ✓ | Default theme-aligned. |
allowEraser |
bool | ✓ | Default true. |
mirrorToHost |
bool | ✓ | If true, Client → Host stroke mirror is live (one frame per stroke segment). Default true. |
scoring |
enum | ✓ | manual (default — quizmaster awards points via Remote override) or none (no points). v1 has no automated drawing scoring. |
Stroke-segment messages travel as objectMessage envelopes per Live Play Protocol — Object-Message Extensions. Payload shape:
{ "kind": "strokeSegment", "body": { "strokeId": "s-1", "points": [[x1, y1], [x2, y2], ...], "isFinal": false } }
Coords are normalised 0..1 against the canvasAspect rectangle.
core.buzzer-input
"objectTypeData": {
"schemaVersion": 1,
"buttonLabel": "BUZZ!",
"armDelayMs": 0,
"winnerCount": 1,
"jingleResolution": "perTeam"
}
| Field | Type | Required | Description |
|---|---|---|---|
buttonLabel |
string | ✓ | Default "BUZZ!". 1…20 chars. |
armDelayMs |
integer | ✓ | Ms after slide entry before presses are accepted. Default 0. Range 0…10000. |
winnerCount |
integer | ✓ | Number of "first-press" winners the Host records before ignoring further presses. Default 1. Range 1…10. |
jingleResolution |
enum | ✓ | perTeam (Host plays the winning team's chosen jingle from manifest.buzzers[] per F-HO-27), none (silent — for testing or non-music quizzes). Default perTeam. |
Press message: objectMessage { kind: "press", body: { pressedAtClientMs: 12345 } }. Host disambiguates ties by Host.receivedAtSessionMs.
core.ranking-input
"objectTypeData": {
"schemaVersion": 1,
"items": [
{ "id": "it-1", "label": "1969", "imageResourceId": null },
{ "id": "it-2", "label": "1989", "imageResourceId": null },
{ "id": "it-3", "label": "2007", "imageResourceId": null },
{ "id": "it-4", "label": "2020", "imageResourceId": null }
],
"correctOrder": ["it-1", "it-2", "it-3", "it-4"],
"shuffleOnPresent": true,
"scoringMode": "perPosition",
"pointsPerCorrectPosition": 1
}
| Field | Type | Required | Description |
|---|---|---|---|
items[].id |
string | ✓ | Stable per-item id. |
items[].label |
string | optional | At least one of label or imageResourceId must be non-null. |
items[].imageResourceId |
string | null | optional | SHA-256 of bundled image. |
correctOrder |
string[] | ✓ | Permutation of all items[].id. |
shuffleOnPresent |
bool | ✓ | If true, the Client renders a per-team random initial order. Default true. |
scoringMode |
enum | ✓ | allOrNothing (full marks only on exact match) or perPosition (1 point per item in correct position). Default perPosition. |
pointsPerCorrectPosition |
integer | conditional | Required when scoringMode == perPosition. Range 0…10. Default 1. |
Beta-cohort schemas
These types land in Beta. Their objectTypeData payloads:
core.categories-input
"objectTypeData": {
"schemaVersion": 1,
"prompt": "Name 5 European capitals.",
"lineCount": 5,
"acceptedAnswers": [
["Paris", "paris"],
["London", "london"],
["Berlin", "berlin"],
["Madrid", "madrid"],
["Rome", "roma", "rome"]
],
"matchMode": "caseInsensitiveTrim",
"scoringMode": "perLine",
"pointsPerCorrectLine": 1,
"allowAnyOrder": true
}
| Field | Type | Required | Description |
|---|---|---|---|
prompt |
string | ✓ | Question prompt. 1…200 chars. |
lineCount |
integer | ✓ | Number of input lines presented to the Client. Range 2…20. |
acceptedAnswers |
string[][] | ✓ | One inner array per correct answer the slide is looking for; each inner array is a set of accepted synonyms. Length matches the intended correct count (commonly equal to lineCount but may be more if the prompt is "name N of M"). |
matchMode |
enum | ✓ | Same enum as core.free-text-input. Default caseInsensitiveTrim. |
scoringMode |
enum | ✓ | perLine (default) or allOrNothing. |
pointsPerCorrectLine |
integer | conditional | Required when scoringMode == perLine. Range 0…10. Default 1. |
allowAnyOrder |
bool | ✓ | If true, the Client's lines are matched against the answer set without regard to which line each one is in. Default true. |
core.match-pairs-input
"objectTypeData": {
"schemaVersion": 1,
"leftColumn": [
{ "id": "L1", "label": "France", "imageResourceId": null },
{ "id": "L2", "label": "Germany", "imageResourceId": null }
],
"rightColumn": [
{ "id": "R1", "label": "Paris", "imageResourceId": null },
{ "id": "R2", "label": "Berlin", "imageResourceId": null }
],
"correctPairs": [ ["L1", "R1"], ["L2", "R2"] ],
"shuffleRightColumn": true,
"scoringMode": "perPair",
"pointsPerCorrectPair": 1
}
| Field | Type | Required | Description |
|---|---|---|---|
leftColumn[], rightColumn[] |
object[] | ✓ | At least 2 items per column. Each item has id, optional label, optional imageResourceId (at least one of label / image required). |
correctPairs |
[leftId, rightId][] |
✓ | Length must equal both columns; each leftId appears in exactly one pair, each rightId in exactly one pair. |
shuffleRightColumn |
bool | ✓ | Default true. |
scoringMode |
enum | ✓ | perPair (default), allOrNothing. |
pointsPerCorrectPair |
integer | conditional | Required when scoringMode == perPair. Range 0…10. Default 1. |
core.mini-game
"objectTypeData": {
"schemaVersion": 1,
"miniGameId": "core.mini-game.tile-tap",
"miniGameVersion": 1,
"durationMs": 30000,
"scoringMode": "perEvent",
"pointsPerEvent": 1,
"config": { ... }
}
| Field | Type | Required | Description |
|---|---|---|---|
miniGameId |
string | ✓ | Namespaced id under core.mini-game.* for shipped built-ins, bundle.mini-game.* if a bundled mini-game ever ships (Stretch). |
miniGameVersion |
integer | ✓ | Version of the named mini-game's schema. Negotiated against the app's mini-game registry via the same rules as object types. |
durationMs |
integer | ✓ | Independent of slide.timing.durationMs — most mini-games run their own clock. Range 5000…300000. |
scoringMode |
enum | ✓ | perEvent (e.g. tile tap), final (mini-game emits a single score at completion), none. |
pointsPerEvent |
integer | conditional | Required when scoringMode == perEvent. |
config |
object | ✓ | Mini-game-specific payload. Owned by the named mini-game's schema — see Mini-Game Framework. |
| Field | Type | Required | Description |
|---|---|---|---|
scope |
enum | ✓ | cumulative (whole-quiz scores) or roundOnly (scores accrued in the current round). Default cumulative. |
ordering |
enum | ✓ | descendingPoints (default), ascendingPoints (rare — "lowest score wins"). |
rowLimit |
integer | ✓ | 0 = show all teams. N > 0 = top-N only. Range 0…50. |
revealAnimation.kind |
enum | ✓ | none, fullTable, rowByRow. |
revealAnimation.rowStaggerMs |
integer | conditional | Required when kind == rowByRow. Ms between row reveals. Range 50…2000. Default 250. |
revealAnimation.direction |
enum | conditional | topDown or bottomUp. Required when kind == rowByRow. Default bottomUp. |
showAvatars |
bool | ✓ | Default true (Alpha onwards — see F-HO-26). MVP renders avatar placeholders. |
showScores |
bool | ✓ | Default true. |
highlightTopN |
integer | ✓ | Top-N rows get the "winner" visual treatment (gradient + glow). Range 0…10. Default 3. |
Versioning
Schema versioning operates at two levels:
- Package schema version —
manifest.schemaVersionandslide.schemaVersion. Currently1for both. A bump signals a structural change to the manifest or slide shape. The Host refuses unknown future versions; the Designer migrates older versions on load (one migration step per version bump, stored inQuiz.Core.SchemaMigrations). - Object-type schema versions — per-type, declared in
manifest.objectTypes[].schemaVersionand stamped per-element inelement.typeVersion. Version-negotiation rules live in Object-Type Contract — Version negotiation.
A .quiz is rejected when:
manifest.schemaVersionis unknown to the app.- Any slide file's
schemaVersionis unknown to the app. - Any declared object-type id is unknown to the app's built-in registry.
- Any declared object-type version is incompatible with the app's built-in version (per Object-Type Contract).
- Any element references a
regionId/roundIdthat does not resolve. - Any region's rect overlaps another region's rect on the same canvas.
- Any required field is absent or out of range.
A .quiz is accepted with a warning when:
tiebreakerRuleis set on a slide with nocore.numeric-inputelement.- An option labels exceeds 60 chars (renders but may wrap awkwardly).
- A
lateSubmissionPenaltyis present butlateSubmissionRuleisnone(penalty ignored).
The validator runs in Quiz.Core.SchemaValidator and is shared by Designer (on save), Host (on load), and Client (on receive after eager push).
Round-trip property
The schema is lossless round-trip: deserialise → serialise must produce byte-identical output for any conformant package, given the documented field order and formatting conventions. The build plan's Round-trip serialization tests for every schema item (Shared schema) enforces this with property-based tests in com.quiz.core/Tests/.
Cross-links
- Quiz Package Format — how these JSON files are bundled inside the
.quizarchive. - Object-Type Contract — the C# interface every object type implements; owns
objectTypeData's shape per type. - Live Play Protocol — how the slide schema crosses the wire at runtime.
- Object-Type Architecture — built-in catalogue + plugin-model overview.
- Design Specification — Typography — the font tokens
core.textreferences. - Open Questions #2 — the question this page resolves.
Live Play Protocol
The wire-level protocol the Host runs with connected Clients and the paired Remote during a live quiz session. Covers the message envelope, the three message families (Designer transfer family lives in Transfer Protocol; live-play + control family are here), timer authority and tick cadence, override semantics, reconnection, and crash-recovery snapshot format.
This page pins what the Networking narrative refers to as the "(planned) Live Play Protocol document". The transport (WebSocket over local Wi-Fi, SimpleWebTransport in the Unity Host, the browser WebSocket API via rxjs/webSocket in the Tauri + Angular Designer) is described in Networking and Tech Stack.
Conventions
- Frames are text WebSocket frames containing UTF-8 JSON. No binary frames in live play. (Binary frames are used by Transfer Protocol only.)
- JSON uses camelCase field names. Compact serialisation (no extra whitespace) on the wire.
- All envelope ids and message ids are UUIDv4 strings.
- Timestamps are integer milliseconds since the Host's session start (
sessionElapsedMs). Wall-clock UTC is included only on the initialsessionHellofor log-correlation. - All durations are integer milliseconds unless suffixed.
Envelope
Every frame in the live-play and control families carries this envelope:
{
"v": 1,
"family": "live-play",
"id": "msg-...",
"ts": 12345,
"type": "answerSubmit",
"payload": { ... }
}
| Field | Type | Required | Description |
|---|---|---|---|
v |
integer | ✓ | Protocol version. Current: 1. A bump is a structural change to the envelope itself; the inner type-keyed payloads bump independently via their own payloadVersion where they have one. |
family |
enum | ✓ | live-play or control. The Host routes by family. Single connection per family per peer. |
id |
UUIDv4 | ✓ | Per-frame id. Used for correlation, ack, and replay. |
ts |
integer | ✓ | Sender's sessionElapsedMs when the frame was queued. Clock-skew tolerant — used for ordering, not authority. |
type |
string | ✓ | Message type discriminator. See per-family catalogues below. |
payload |
object | ✓ | Type-specific payload. May be {} for ping-style messages. |
Out-of-spec frames (missing field, unknown type within a known family, v > 1) are dropped and a protocolError envelope is sent back identifying the offending id.
Connection lifecycle
The path discriminates the family: /live-play for team Clients, /control for the Remote.
sessionHello
{ "type": "sessionHello", "payload": {
"sessionId": "ses-...",
"quizId": "quiz-...",
"schemaVersion": 1,
"protocolVersion": 1,
"sessionStartedAtUtc": "2026-05-13T19:00:00Z",
"sessionElapsedMs": 0,
"hostBuild": "0.4.2"
} }
hello (peer → host)
{ "type": "hello", "payload": {
"peerKind": "client",
"clientToken": "ct-...",
"teamName": "The Quizotics",
"appBuild": "0.4.2",
"capabilities": { "audioPlayback": true, "cameraCapture": true }
} }
peerKind:clientorremote.clientToken/remoteToken: device-stable token persisted to local storage on first join. Empty on first-ever connect (Host mints one and returns it inhelloAck).
helloAck
{ "type": "helloAck", "payload": {
"peerId": "peer-...",
"teamId": "team-...",
"issuedClientToken": "ct-...",
"resumeSeq": 0
} }
resumeSeq is 0 on fresh join; on reattach, it is the highest sequence the Host knows it has delivered to this peer, so the Client knows what to expect on snapshot replay.
Message families
live-play (Client ↔ Host)
| Direction | Type | Purpose |
|---|---|---|
| H → C | sessionHello, helloAck, sessionSnapshot, teamRosterUpdate, slideEnter, slideAdvance, elementReveal, timerTick, timerLock, timerUnlock, scoreUpdate, leaderboardSnapshot, objectMessage (host-bound), protocolError, sessionEnd |
Drive Client state. |
| C → H | hello, pong, answerSubmit, objectMessage (client-bound), protocolError |
Submit answers + object-type-specific traffic. |
| Either | ping / pong |
Liveness + RTT measurement. |
eagerPushBegin → eagerPushChunk → eagerPushEnd (Host → Client on first connect)
The eager-push body crosses the wire as a sequence of these frames immediately after helloAck. Payload schema:
// eagerPushBegin
{ "type": "eagerPushBegin", "payload": {
"totalBytes": 1245376,
"chunkBytes": 65536,
"chunkCount": 20,
"contentDigestSha256": "..."
} }
// eagerPushChunk
{ "type": "eagerPushChunk", "payload": {
"seq": 0,
"bytesBase64": "..."
} }
// eagerPushEnd
{ "type": "eagerPushEnd", "payload": {} }
Late-joining Clients show a progress UI driven by eagerPushBegin.totalBytes + the running sum of eagerPushChunk.bytesBase64 lengths.
slideEnter / slideAdvance
// slideEnter
{ "type": "slideEnter", "payload": {
"slideId": "sl-...",
"slideIndex": 3,
"enteredAtSessionMs": 32450
} }
slideAdvance is the inverse — emitted on slide leave with nextSlideId (or null if the quiz ends).
elementReveal
{ "type": "elementReveal", "payload": {
"slideId": "sl-...",
"elementId": "el-...",
"trigger": "onCue",
"animation": "fade",
"animationDurationMs": 350
} }
Authoritative reveal command. Fired automatically by the Host for onEntry / afterDelay triggers, and on operator / Remote input for onCue.
timerTick, timerLock, timerUnlock
See Timer authority.
answerSubmit
{ "type": "answerSubmit", "payload": {
"slideId": "sl-...",
"elementId": "el-...",
"submission": { ... },
"clientSubmittedAtMs": 28450
} }
submission is type-specific:
core.multiple-choice-input:{ "optionId": "opt-a" }core.free-text-input:{ "text": "Paris" }core.numeric-input:{ "value": 100 }core.true-false-input:{ "value": true }
The Host validates against the slide's objectTypeData and the current timer state, applies the scoring rule (including lateSubmissionRule if the lock has fired), and emits a scoreUpdate.
scoreUpdate
{ "type": "scoreUpdate", "payload": {
"teamId": "team-...",
"slideId": "sl-...",
"delta": 1,
"newTotal": 7,
"reason": "correct"
} }
reason: correct, incorrect, latePenalty, quizmasterOverride, revert.
objectMessage (Object-Message Extensions)
Object types with an IProtocolExtension (see Object-Type Contract) send and receive via:
{ "type": "objectMessage", "payload": {
"objectTypeId": "core.buzzer-input",
"messageNamespace": "core.buzzer",
"elementId": "el-...",
"kind": "press",
"schemaVersion": 1,
"body": { ... }
} }
The Host routes inbound objectMessage frames to the matching element's runtime via the registry (IObjectTypeRegistry.Get(objectTypeId)), which Deserializes the body into the extension's IObjectMessage type and calls IHostRuntime.OnProtocolMessage. Same path on Client.
protocolError
{ "type": "protocolError", "payload": {
"code": "unknownType",
"offendingId": "msg-...",
"detail": "Object type core.unknown-thing not registered."
} }
Codes: unknownType, unknownFamily, versionMismatch, validationFailed, lockedInputRejected, late, tokenInvalid, internalError. The receiver should log and continue; only internalError / tokenInvalid cause the Host to drop the connection.
control (Remote ↔ Host)
| Direction | Type | Purpose |
|---|---|---|
| H → R | sessionHello, helloAck, sessionSnapshot, slideEnter, slideAdvance, timerTick, timerLock, timerUnlock, scoreUpdate, leaderboardSnapshot, hostMirrorFrame, hostNotes, protocolError, sessionEnd |
Mirror + telemetry. |
| R → H | hello, pong, advance, goBack, jumpToSlide (Alpha+), cueReveal (Alpha+), lockInput (Alpha+), unlockInput (Alpha+), extendTimer (Alpha+), skipTimer (Alpha+), overrideScore (Alpha+), protocolError |
Commands. |
| Either | ping / pong |
Liveness. |
hostMirrorFrame
{ "type": "hostMirrorFrame", "payload": {
"frameSeq": 482,
"capturedAtSessionMs": 31200,
"widthPx": 480,
"heightPx": 270,
"encoding": "image/jpeg",
"qualityHint": 60,
"framePayloadBase64": "..."
} }
Default cadence: 5 Hz, 480 × 270 px, JPEG quality 60. Tuned for ≤ 30 KB/frame so the LAN budget is ~150 KB/s per paired Remote (well below the LAN target). Operator can disable mirror entirely from Host settings (telemetry-only mode) for low-spec Remote devices.
hostNotes
{ "type": "hostNotes", "payload": {
"slideId": "sl-...",
"markdown": "**Answer:** Paris."
} }
Emitted on every slideEnter. The Remote re-renders.
advance, goBack
Empty payload: {}. Idempotent — the Host echoes a slideAdvance whether or not the command produced a slide change (clamped at quiz boundaries).
jumpToSlide (Alpha)
{ "type": "jumpToSlide", "payload": {
"slideId": "sl-..."
} }
cueReveal (Alpha)
{ "type": "cueReveal", "payload": {
"slideId": "sl-...",
"elementId": "el-..."
} }
The Host validates that elementId is on slideId and has reveal.trigger == onCue; otherwise emits protocolError with validationFailed.
lockInput, unlockInput (Alpha)
{ "type": "lockInput", "payload": { "slideId": "sl-..." } }
Issues an authoritative timerLock (resp. timerUnlock) on the live-play family without changing the timer's countdown — the timer keeps ticking, only input is gated.
extendTimer, skipTimer (Alpha)
{ "type": "extendTimer", "payload": { "slideId": "sl-...", "deltaMs": 30000 } }
{ "type": "skipTimer", "payload": { "slideId": "sl-..." } }
extendTimer.deltaMs: positive adds, negative subtracts (down to 0). The Host computes the new slideRemainingMs and emits a fresh authoritative timerTick.
skipTimer: equivalent to jumping the countdown to 0. Triggers timerLock if the slide's lockOnTimeUp == true.
overrideScore (Alpha)
{ "type": "overrideScore", "payload": {
"teamId": "team-...",
"slideId": "sl-...",
"newDelta": 2,
"reasonNote": "Accepted alternate spelling"
} }
Replaces the team's slide-scoped delta with newDelta (positive or negative). The Host emits a scoreUpdate with reason: quizmasterOverride and a revert for the prior delta.
Timer authority
Tick cadence
The Host is the single source of truth for slide time. It emits timerTick to all connected live-play peers and to the paired control peer at 5 Hz (every 200 ms) during an active timer. The Client renders a local 60 Hz countdown extrapolated from the most recent tick and reconciles whenever a new tick arrives.
{ "type": "timerTick", "payload": {
"slideId": "sl-...",
"mode": "countdown",
"remainingMs": 12200,
"elapsedMs": 17800,
"isLocked": false,
"tickAtSessionMs": 30000
} }
| Setting | Value | Reason |
|---|---|---|
| Tick rate | 5 Hz / 200 ms | Cheap (< 80 B per frame × N clients = trivial bandwidth) and well below the human perception threshold for skipping seconds. |
| Reconciliation tolerance | ±500 ms | Client-side countdown reseats to authoritative value if drift exceeds this. Under this threshold the Client interpolates smoothly. |
| Lock latency target | < 200 ms wall-clock | From time-up event on Host to timerLock received by Client. Network-bounded; soak-tested in Alpha. |
| Manual override propagation | next tick | A timerExtend / timerSkip always emits a fresh tick before returning — Clients never observe the new state more than one tick later. |
Lock / unlock
{ "type": "timerLock", "payload": { "slideId": "sl-...", "reason": "timeUp", "lockedAtSessionMs": 60000 } }
{ "type": "timerUnlock", "payload": { "slideId": "sl-...", "reason": "operatorOverride", "unlockedAtSessionMs": 62000 } }
Reasons: timeUp, operatorOverride, remoteCommand. Late submissions arriving after timerLock are accepted only if the slide's lateSubmissionRule != none; otherwise the Host emits a protocolError with code: late to the originating Client.
Untimed slides
slide.timing.durationMs == 0 means no countdown. The Host emits no timerTick frames and no timerLock for these slides. The operator can still manually lock input via the Host operator window or paired Remote (lockInput).
Reconnection
A Client reconnects with the same clientToken. The Host treats this as a reattach to the existing team:
- The Client opens a fresh WebSocket to
/live-play. - The Host issues
sessionHellowith the samesessionId. - The Client sends
hellowithclientTokenset andresumeFromSeqset to the last known sequence. - The Host issues
helloAckwith the originalteamId, then replays asessionSnapshot(below) — the current quiz state — followed by any messages withseq > resumeFromSeq. - The Client may render a re-sync banner during the snapshot replay.
A Remote reconnect is identical, with remoteToken instead of clientToken. The Remote re-receives the mirror stream and host-notes for the current slide.
If the clientToken is unknown (Host snapshot was wiped, or this is a fresh device), the Host responds with helloAck { resumeSeq: 0, teamId: null } and the Client falls back to the join screen — same path as first-ever join.
sessionSnapshot
{ "type": "sessionSnapshot", "payload": {
"sessionElapsedMs": 47200,
"currentSlideId": "sl-...",
"currentSlideIndex": 7,
"slideEnteredAtMs": 42000,
"timer": { "mode": "countdown", "remainingMs": 15000, "isLocked": false },
"scoresByTeamId": { "team-...": 7, "team-...": 4 },
"revealedElementIds": ["el-...", "el-..."],
"lastDeliveredSeq": 482
} }
The snapshot is strictly idempotent — the Client must replay over the snapshot and converge to identical state regardless of when it arrives or how many times it arrives. Object-type-specific state (timer remaining-as-of, leaderboard reveal index) lives in revealedElementIds + per-element state queries via the runtime adapter; types flagged Capabilities.IsStateful contribute their own snapshot in:
"objectStates": [
{ "elementId": "el-timer-1", "objectTypeId": "core.timer", "schemaVersion": 1, "state": { "remainingMs": 15000 } },
{ "elementId": "el-leaderboard-1", "objectTypeId": "core.leaderboard", "schemaVersion": 1, "state": { "lastRevealedRowIndex": 4 } }
]
The runtime adapter calls IHostRuntime.OnProtocolMessage with a synthesised restore objectMessage to feed each stateful type its prior snapshot — or, equivalently, types implement an explicit RestoreState(JsonElement) method (added to IHostRuntime when the Alpha crash-recovery work lands).
Crash recovery
The Host writes the same sessionSnapshot shape to disk after every scoring event and every slideAdvance. The on-disk format is identical to the wire format plus an outer envelope:
{
"snapshotVersion": 1,
"writtenAtUtc": "2026-05-13T19:42:18Z",
"quizId": "quiz-...",
"quizDigestSha256": "...",
"session": { ... sessionSnapshot.payload ... },
"teamsByTeamId": {
"team-...": {
"teamId": "team-...",
"teamName": "The Quizotics",
"clientToken": "ct-...",
"score": 7,
"avatar": { "kind": "premade", "avatarId": "av-001" },
"buzzerJingleId": "buz-001"
}
}
}
Snapshot file: <host-app-data>/sessions/<sessionId>/snapshot.json. Atomic write via .tmp → fsync → rename.
quizDigestSha256 is computed over the .quiz manifest at session start; on relaunch, if the loaded quiz's digest does not match, the Host refuses to resume and prompts to start fresh.
The snapshot retention rule: keep the last 5 completed sessions per quizmaster device + the active session if any. Older are GC'd on Host launch.
Liveness
ping / pong runs on every connection at 15 s intervals from the Host. If the Host receives no pong (or any other frame) for 45 s, the connection is considered dead and is closed; the peer becomes a reconnect candidate. The Client / Remote keep their clientToken / remoteToken and attempt reconnect with exponential backoff (1 s, 2 s, 4 s, 8 s, capped at 30 s, indefinite retries).
Versioning
The envelope's v field is the protocol version. Per-message-type schemas evolve via additive fields only; unknown fields are tolerated by the deserialiser. A breaking change to any payload bumps the envelope v. The Host refuses connections with mismatched v and surfaces an operator banner ("Client app is out of date — update to play.").
Cross-links
- Networking — transport, gating, the three families.
- Object-Type Contract —
IProtocolExtensionand howobjectMessageenvelopes are routed. - Slide Schema — payload field shapes the protocol references (
reveal,timing,objectTypeData). - Transfer Protocol — the Designer → Host package transfer family (binary frames, distinct from this page's text frames).
- Open Questions #7 — Designer→Host wire-level details (resolved 2026-05-11; live in Transfer Protocol).
- Open Questions #12 — team-photo size + transmit budget (resolved 2026-05-11; lives in the team-join section of Networking).
Transfer Protocol
The wire-level protocol for moving a .quiz package from the Designer to the Host over local Wi-Fi. Separate from the live-play and control families (see Live Play Protocol) — one transport (WebSocket), distinct family, distinct framing.
This page pins what Networking — Designer→Host package transfer refers to as the Transfer Protocol document. Defaults — 64 KB chunks, CRC32 per chunk, resume-from-offset, no extra wire compression — sit on the framing schema below.
Family
The Designer opens a WebSocket to /transfer on the Host. Two frame kinds:
- Text frames — JSON envelopes carrying control messages (
transferOffer,transferAccept,transferReject,chunkAck,transferComplete,transferError). - Binary frames — raw chunk payloads. The accompanying metadata (sequence, CRC32) is carried in the opcode-prefixed binary header below, not in a separate text frame, so the binary stream is self-describing.
This is the only family that uses binary frames. Live-play + control are pure text JSON.
Binary chunk frame
Each binary WebSocket frame on /transfer is:
+--------+--------------+--------+----------------+
| 1 byte | 4 bytes (BE) | 4 bytes (BE) | N bytes
+--------+--------------+--------+----------------+
| op=0x01| seq | crc32 | chunkBytes
+--------+--------------+--------+----------------+
| Field | Size | Description |
|---|---|---|
op |
1 byte | 0x01 = chunk. Reserved: 0x02+ for future binary control. Receiver rejects unknown opcodes with transferError { code: unknownOpcode }. |
seq |
4 bytes, big-endian uint32 | Monotonic, starting at 0, contiguous. The sender retransmits a missing seq with the same seq number on reconnect. |
crc32 |
4 bytes, big-endian uint32 | CRC32 of chunkBytes only (does not include header). Standard polynomial 0xEDB88320. |
chunkBytes |
up to 64 KB | The chunk payload. Final chunk may be smaller. |
Receiver behaviour:
- Verify
crc32ofchunkBytes. On mismatch: emitchunkAck { seq, status: "crcFail" }; sender retransmits. - Verify
seq == expectedSeq. On future seq: queue, emitchunkAck { seq, status: "outOfOrder", expectedSeq }. On past seq (already-acked): emitchunkAck { seq, status: "duplicate" }and drop. - On success: append to receiver's offset file, emit
chunkAck { seq, status: "ok", bytesSoFar }.
Text envelopes
Same envelope shape as Live Play Protocol — Envelope but with family: "transfer":
{ "v": 1, "family": "transfer", "id": "msg-...", "ts": 12, "type": "transferOffer", "payload": { ... } }
transferOffer (Designer → Host)
{ "type": "transferOffer", "payload": {
"quizId": "quiz-...",
"title": "My Live Quiz",
"totalBytes": 52428800,
"manifestDigestSha256": "...",
"packageDigestSha256": "...",
"designerLabel": "Adam's Mac (Designer 0.4.2)",
"schemaVersion": 1,
"declaredObjectTypes": [
{ "id": "core.text", "schemaVersion": 1 },
{ "id": "core.multiple-choice-input", "schemaVersion": 1 }
],
"resumeSupported": true
} }
The Host shows the operator-confirm prompt — "Adam's Mac wants to send 'My Live Quiz' (50 MB). Accept?" — and pre-resolves the declared object types against its built-in registry. If any are missing or version-incompatible, the Host can pre-reject with an actionable message without accepting the transfer.
transferAccept (Host → Designer)
{ "type": "transferAccept", "payload": {
"transferId": "tx-...",
"chunkBytes": 65536,
"resumeFromOffsetBytes": 0,
"resumeFromSeq": 0
} }
resumeFromOffsetBytes / resumeFromSeq are non-zero only on a resume — see Resume.
transferReject (Host → Designer)
{ "type": "transferReject", "payload": {
"reason": "operatorDeclined" | "unknownObjectType" | "versionMismatch" | "schemaUnsupported" | "tooLarge" | "diskFull" | "internalError",
"detail": "Object type core.unknown-thing version 1 is not built into this Host build (1.2.3)."
} }
The Designer surfaces detail verbatim in its push-to-Host dialog.
chunkAck (Host → Designer, one per chunk)
{ "type": "chunkAck", "payload": {
"seq": 42,
"status": "ok" | "crcFail" | "outOfOrder" | "duplicate",
"bytesSoFar": 2752512,
"expectedSeq": 43
} }
The Designer streams chunks flow-controlled by acks — sliding window of 8 chunks (default), bumpable by Host setting chunkAck.payload.windowHint. This keeps the receiver from backing up on slow disk writes without going single-chunk-blocking.
transferComplete (Host → Designer)
{ "type": "transferComplete", "payload": {
"transferId": "tx-...",
"bytesReceived": 52428800,
"packageDigestSha256": "...",
"verifiedAtUtc": "2026-05-13T19:42:18Z",
"loadedIntoLibrary": true
} }
packageDigestSha256 is the SHA-256 the Host computed over the full received bytes; the Designer cross-checks against transferOffer.packageDigestSha256 and surfaces a transfer-failed error if they differ.
loadedIntoLibrary == true means the Host has saved the .quiz into its local library and is ready to start a session from it. false means the Host received the bytes but the manifest validation failed downstream — the Designer surfaces the validation error.
transferError (either direction)
{ "type": "transferError", "payload": {
"code": "crcFailExhausted" | "unknownOpcode" | "diskFull" | "timeout" | "schemaInvalid" | "internalError",
"detail": "Chunk 42 failed CRC 3 times; aborting."
} }
crcFailExhausted: a chunk has failed CRC the configured retry count (default 3) in a row. The sender aborts and the transfer is abandoned; partial state on disk is left for resume.
Resume
On Designer reconnect (Wi-Fi blip, app crash, deliberate retry):
- Designer re-opens the WebSocket and emits
transferOfferwith the samequizId+packageDigestSha256. - Host looks up its on-disk transfer state for this
(quizId, packageDigestSha256)pair. If found and the package digest matches, the Host responds withtransferAcceptsettingresumeFromOffsetBytesto the byte position one past the last successfully-acked chunk andresumeFromSeqtolastAckedSeq + 1. - Designer seeks to that offset and resumes chunk streaming from the next
seq.
If the package digest in the new offer differs from the stored state, the Host treats it as a fresh transfer (the package has been edited since last attempt) and starts from seq: 0. The stale partial is GC'd.
Partial-transfer retention: 24 hours since last byte received. Older are GC'd on Host launch. Hard cap of 10 partial transfers on disk — oldest is evicted when an 11th arrives.
Validation and rejection
The Host performs three checks before transferComplete:
- Stream integrity — per-chunk CRC32 (handled inline).
- Whole-payload integrity — SHA-256 over the assembled bytes, cross-checked against
transferOffer.packageDigestSha256. - Manifest validation — once the file is on disk, the Host unzips, parses
manifest.json+ everyslides/*.json, and runsQuiz.Core.SchemaValidator. On any validation error: emittransferComplete { loadedIntoLibrary: false }followed by atransferError { code: schemaInvalid, detail: ... }.
Object-type resolution is pre-checked in transferOffer (declared list) and re-validated against the final manifest after step 2 — the Designer's declared list and the package's manifest should match, but the post-receive re-check is the authoritative gate. A mismatch surfaces as schemaInvalid with a specific message ("manifest declares 4 types; offer declared 3").
Per-chunk progress UI
The Designer's push dialog renders a progress bar driven by bytesSoFar / totalBytes from chunkAck. The Host's accept screen shows the same bar (operator's view of the incoming push). Both refresh on every ack (≈ 15 Hz at full LAN throughput; less on slow links).
Wire compression
None. .quiz files are zip archives; double-compressing wastes CPU on both ends with negligible size benefit. The WebSocket permessage-deflate extension is negotiated off by the Host for /transfer connections.
Liveness
ping / pong runs at 30 s intervals on /transfer (less aggressive than live-play because the heavy traffic is the binary stream — successful chunk acks are themselves liveness). Idle window before drop: 120 s.
Defaults summary
| Setting | Default | Source |
|---|---|---|
| Chunk size | 64 KB | This page |
| Send window | 8 chunks | This page (tunable per-Host) |
| Per-chunk CRC retry budget | 3 | This page |
| Resume retention | 24 h / 10 partials | This page |
| Liveness ping | 30 s | This page |
| Idle-drop | 120 s | This page |
| Wire compression | off | This page |
| Max package size | 200 MB | Package Format |
Versioning
Same rules as Live Play Protocol — Versioning. The envelope's v field is the family-shared protocol version; binary-chunk framing has its own opcode (op: 0x01) which is itself a version-extension surface (new opcodes = additive; existing opcode framing is frozen).
Cross-links
- Networking — Designer→Host package transfer — the narrative this page is the schema for.
- Live Play Protocol — sibling protocol, distinct family.
- Slide Schema — what's inside
manifest.jsonandslides/*.jsonthat the Host validates after receive. - Object-Type Contract — Version negotiation — the rules behind
transferRejectreason: versionMismatch. - Open Questions #7, #8 — resolutions this page implements.
Authentication
v1: No authentication on any app. The Designer, Host, and Client all run unauthenticated. Designer→Host transfer is gated by being on the same local network (and the Host accepting the incoming connection through its UI), not by credentials. See Networking for the transfer model.
Stretch goal (alongside cloud-backed authoring):
- Author / Host: cloud account sign-in with email magic link as the universal path, plus Sign in with Apple (required when distributed via the iOS App Store), Google, and optionally Microsoft. The same account is used on both apps. Auth provider (managed Postgres + auth-as-a-service / cloud-native identity / similar) decided when this stretch is promoted. The data the Author authenticates against is laid out in Backend Schema.
- Client: Still no authentication — each team enters a team name on joining a session (one shared Client device per team). Team names are ephemeral and not stored beyond the session.
Backend Schema — stretch goal, not v1
Cloud-backed authoring (account, library, Designer→cloud upload, Host→cloud download) is in Stretch. v1 has no cloud backend; the Designer saves .quiz files to disk and transfers them to the Host over the local network — see Networking. The schema below sketches the entities so the v1 architecture stays compatible with the eventual cloud path. The vendor (managed Postgres + S3-compatible object storage) is decided when the stretch is promoted; the SQL shape below is illustrative, not vendor-specific.
profiles
id uuid PK references the auth provider's user id
display_name text
created_at timestamptz
quizzes
id uuid PK
owner_id uuid FK → auth user id
title text
description text
tags text[]
current_version_id uuid FK → quiz_versions
created_at timestamptz
updated_at timestamptz
deleted_at timestamptz -- soft delete
quiz_versions
id uuid PK
quiz_id uuid FK → quizzes
version_number int
storage_path text -- path in object storage
size_bytes bigint
notes text
pinned boolean default false -- protect from version pruning
created_at timestamptz
Storage layout: quizzes/{owner_id}/{quiz_id}/v{n}.quiz
Tenant isolation: users can SELECT/INSERT/UPDATE/DELETE only rows where owner_id matches their authenticated identity. Object-storage paths are similarly scoped to the user's own prefix. Whether this is enforced via row-level security at the database or via the application layer depends on the chosen vendor, decided when this stretch is promoted.
Version retention: the latest 20 versions per quiz are kept, plus any versions marked pinned = true. Older versions are pruned by a scheduled job. Soft-deleted quizzes are recoverable from a "trash" view for 30 days.
Auth flows on top of this schema are covered in Authentication.
CI / CD
The project uses Azure DevOps Pipelines for continuous integration and deployment. Source lives in Azure Repos; pipelines are defined in YAML in this repo and run on a mix of Microsoft-hosted agents and a project-owned self-hosted Mac mini agent.
Why Azure DevOps Pipelines
- Single platform with project management. Issues, boards, repos, pipelines, wikis, and artefacts all under one tenant (Azure DevOps for project management covers the boards / sprint side).
- Self-hosted macOS agents are first-class. Apple-only build steps (iPad / iOS Unity Player builds, the Tauri macOS bundle, code-signing, notarisation) require a Mac, and the project's Mac mini covers them without paying for a cloud-Mac runner.
- Unity build pipeline support. Unity Editor activations, license caching, and Player builds for Win / macOS / iPad / Android run cleanly under ADO Pipelines tasks, including the Quiz.Preview WebGL build.
Agent pool layout
| Agent | Hosted by | Purpose |
|---|---|---|
Azure Pipelines (Microsoft-hosted) |
Microsoft | Windows builds (Quiz.Designer Tauri Windows bundle, Quiz.Host / Client / Remote Unity native Windows player), schema codegen, Angular build + Jest unit tests + Playwright screenshots, shared C# analysers + tests, .NET Standard 2.1 library tests, generic CI tasks. |
MacMini (self-hosted) |
Project | macOS + iPad / iOS builds (Quiz.Designer Tauri macOS DMG, Quiz.Host / Client / Remote Unity macOS + iOS Player builds), Quiz.Preview WebGL build, code-signing, notarisation. |
The Mac mini runs the latest macOS supported by Apple's developer toolchain, has the current Xcode + iOS / macOS SDKs installed plus the Rust toolchain + Tauri prerequisites, and holds the project's signing certificates and provisioning profiles in the macOS Keychain.
Pipelines
Each YAML pipeline lives at the repo root under .azure-pipelines/. Pipelines are intentionally small and composable; multi-stage pipelines orchestrate them.
| Pipeline | Trigger | Agent | What it runs |
|---|---|---|---|
pr-validation.yml |
Pull request | Both pools as needed | Build everything that compiles on each agent + run all fast tests + lint + format. Required check before merge. |
quiz-core.yml |
Push to main touching packages/com.quiz.core/ or its tests |
Microsoft-hosted | dotnet test headless; .NET Standard 2.1 library; no Unity. |
quiz-runtime.yml |
Push to main touching packages/com.quiz.runtime/ |
Mac mini (Unity license) | Unity edit-mode + play-mode tests under com.quiz.runtime/Tests/. |
quiz-host.yml |
Push to main touching Quiz.Host/ |
Mac mini for macOS / iOS Player; Microsoft-hosted for Windows Player | Unity Player builds for the configured Host platforms; play-mode tests. |
quiz-client.yml |
Push to main touching Quiz.Client/ |
Mac mini for macOS / iOS Player; Microsoft-hosted for Windows / Android | Same shape as Host. |
quiz-remote.yml |
Push to main touching Quiz.Remote/ |
Mac mini / Microsoft-hosted as appropriate | Same shape. |
quiz-preview-webgl.yml |
Push to main touching Quiz.Preview/ or packages/com.quiz.shared-assets/ |
Mac mini (Unity license) | Unity WebGL Player build of Quiz.Preview/; output published as a pipeline artefact consumed by quiz-designer.yml. |
quiz-schemas.yml |
Push to main touching schemas/ |
Microsoft-hosted | Runs C# (NJsonSchema) and TypeScript (quicktype + ajv) codegen against schemas/; publishes both code drops as pipeline artefacts consumed by Unity-side and Angular-side builds. |
quiz-designer.yml |
Push to main touching Quiz.Designer/, or successful quiz-preview-webgl.yml / quiz-schemas.yml artefacts |
Microsoft-hosted (Windows bundle); Mac mini (macOS DMG) | Consumes the TS schema artefact and the Quiz.Preview WebGL artefact; runs ng build + Jest unit tests + Playwright screenshot pass against ng serve; runs tauri build to produce signed installers per OS. |
release.yml |
Manual run / git tag | Both pools | Signs, packages, and publishes installers to the configured store / distribution channels. |
Pipelines that need a Unity license use a shared "Unity" service connection holding the activation file; the activation is cached on the Mac mini agent so cold starts are fast.
Caching + artefacts
- NuGet package cache keyed by
**/packages.lock.json. - npm package cache keyed by
Quiz.Designer/package-lock.jsonfor the Angular workspace. - Cargo cache keyed by
Quiz.Designer/src-tauri/Cargo.lockfor the Tauri Rust shell. - Unity Library/ cached per-project per-agent so re-imports skip on incremental builds.
- WebGL build output published as a pipeline artefact and consumed by
quiz-designer.ymlso the Designer build stays decoupled from the Unity build. - Schema codegen output published as a pipeline artefact and consumed by both Unity-side and Angular-side builds.
- Test results published as JUnit XML so the ADO test report aggregates across pipelines.
Branching + PR policy
- Trunk-based;
mainis always green. - Feature work happens on short-lived branches; PRs target
main. pr-validation.ymlis a required status check onmain— branches don't merge without it.mainis build-verified by the per-area pipelines plus a nightlyrelease.ymldry run that does not publish.
Local mirror
.azure-pipelines/ is checked in. Devs can lint pipeline YAML locally with az pipelines runs list and validate with az pipelines validate. The pipeline definitions are owned end-to-end by the repo, not the ADO portal, so changes are reviewed in PR alongside the code they validate.
Testing
How testing is practised on this project. This page is the development-process source of truth — the Build Plan deliberately does not restate test tasks per item; instead the build plan's Definition of Done references this page.
Test-driven development is the default
For feature work, write the failing test first, make it pass with the smallest viable change, then refactor. The "feature work" boundary is anything that delivers user-visible behaviour, exercises a network protocol, or touches scoring/persistence/rendering pipelines.
The following work is excepted from TDD:
- Initial template scaffolding, asmdef/namespace renames, project-settings tweaks, asset relocations, splash skeleton — captured in Per-App Scaffolding. Tests on bootstrap glue are flaky and brittle to scene layout changes; the smoke-test in Editor is sufficient signal at scaffolding time.
- One-off documentation, knowledgebase, or CI configuration changes.
- Dependency upgrades that don't change behaviour.
- Schema-codegen output (
Quiz.Designer/src/app/generated/andpackages/com.quiz.core/Runtime/Generated/). The hand-written tests are on the schemas themselves underschemas/fixtures/and on the consumers of the generated types, not on the generated code.
Anything else: failing test first.
Where each kind of test lives
| Layer | Location | Mode | Why |
|---|---|---|---|
| Pure C# domain — schemas, scoring, protocol state, package format | packages/com.quiz.core/Tests/Runtime/ |
Edit-mode | com.quiz.core is .NET Standard 2.1 with noEngineReferences: true. Tests run headless under NUnit, no Unity Player needed. Fast in CI. |
Designer services — AuthoringSession, CommandDispatcher, persistence + library + transfer service implementations |
Quiz.Designer/src/**/*.spec.ts |
Headless (Jest) | Pure TypeScript with mocked PlatformAdapter. Run via npm test on any platform. |
| Angular components — render output + event handling | Quiz.Designer/src/**/*.component.spec.ts |
Headless (Angular TestBed + Jest) | Component DOM + bindings + interaction. Mocks the services. |
Designer end-to-end — full UI flows against ng serve |
Quiz.Designer/e2e/*.spec.ts |
Headless (Playwright) | Drives Chromium pointed at ng serve. Visual screenshots + interaction. |
| Schema cross-fixture parity — shared TS / C# fixtures | schemas/fixtures/ (consumed by both NUnit and Jest harnesses) |
Headless | Asserts that semantic validation rules implemented on both sides agree on the same inputs. |
| Unity-aware shared adapters — registry impl, slide adapters, networking glue | packages/com.quiz.runtime/Tests/Runtime/ |
Play-mode | Touches MonoBehaviour lifecycle, main-thread marshalling. Requires the Unity Player. |
| App-specific behaviour — Host session, Client team-play, Remote control | Quiz.{App}/Assets/_Game/Tests/ (each Unity app) |
Play-mode | Exercises scenes, prefabs, UI interactions. Per-app because behaviour diverges. |
| App boot-chain smoke (Setup → MainMenu) | Quiz.{App}/Assets/_Game/Tests/Smoke/ |
Play-mode | Per-Unity-app — verifies the boot chain reaches its target scene with zero console errors. Replaces the manual MCP smoke-tests once they exist. |
Each test folder owns its own asmdef referencing only the assemblies under test plus nunit.framework. The com.wildfiregames.core test fixtures (if any) are not used directly — test the project's code, not the third-party.
Conventions
- Test class name mirrors the production class with
Testssuffix:ScoringEngine→ScoringEngineTests. - Fixture-per-class for unit tests; one
[Test]method per behaviour. - Async tests use
[UnityTest]+IEnumeratorfor play-mode,[Test] async Taskis fine for edit-mode under NUnit ≥ 3.13. - Arrange / Act / Assert sections separated by blank lines, no comments to label them — names already do that.
- Avoid
MonoBehaviour.SendMessage, reflection on private members, or scene-dependent fixtures unless the test is explicitly an integration test. - Test data lives next to the test in
TestData/— never reach into productionResources/from tests.
CI gate
Azure DevOps Pipelines runs the test suites on every PR and every push to main:
- Edit-mode suite for
com.quiz.coreruns headless underdotnet test(no Unity, fast). - Designer unit + component suites run via
npm test(Jest + Angular TestBed) on Microsoft-hosted Windows agents. - Designer end-to-end Playwright suite runs against
ng serveon Microsoft-hosted Windows agents. - Schema cross-fixture parity suite runs on both sides —
dotnet teston the C# side,npm teston the TypeScript side — against shared inputs inschemas/fixtures/. - Play-mode suite for
com.quiz.runtimeand each Unity project runs under the Unity test runner on the appropriate agent (Mac mini for macOS / iOS, Microsoft-hosted for Windows / Android). - Tauri Designer build (
tauri build) runs on Microsoft-hosted Windows agents (Windows installer) and on the Mac mini (macOS DMG, signed + notarised) — build-only gate; UI behaviour is exercised by the Angular suites. - A red test fails the pipeline;
pr-validation.ymlis a required check before merge.
Local fast feedback: edit-mode tests run from Unity's Test Runner window in <1 s per fixture; CLI parity via Unity.exe -batchmode -runTests -testPlatform editmode for Unity, dotnet test for com.quiz.core, npm test for the Angular workspace.
Visual verification — Designer UI
Type-checking, Jest, and Angular component tests confirm code correctness, not visual correctness. Designer UI changes additionally render the running surface and compare it against the matching mockup in docs/ui-mockups/designer/ and the Design Specification — Studio. Layout, colour, and typography divergence is a blocker, not a polish item.
- Pure HTML mockups —
python .claude/skills/ui-mockups/scripts/screenshot.py --file docs/ui-mockups/designer/<region>.htmlwrites per-theme PNGs to.build/. - Live Angular components — the Tauri shell hosts the Angular app inside WebView2 / WKWebView, neither of which Playwright drives directly.
ng serveis the Playwright target: same Angular app, plain Chromium.python .claude/skills/ui-mockups/scripts/screenshot.py --url http://localhost:4200/drives Chromium and writes per-theme PNGs to.build/. The Tauri shell's native-chrome surface (custom titlebar, multi-window, mDNS, subprocess spawn) is verified manually viatauri dev.
Visual divergence is treated like a failing test. Anything that ships in the Designer first lands in ng serve, gets screenshot-verified, then runs identically inside the Tauri WebView.
Smoke-test workflow during scaffolding
Before automated play-mode smoke tests are wired up, the scaffolding workflow uses Unity MCP as a lightweight stand-in:
set_active_instanceto the target editor.read_console action=clear.manage_editor action=play.- Wait for the boot chain.
read_console types=["error","warning"].manage_editor action=stop.
This is a manual cross-check, not a regression gate. Once the per-app Tests/Smoke/ suite lands, that suite becomes the gate and the manual MCP procedure is retired.
Cross-references
- Repository Layout — package responsibilities — describes the
com.quiz.core/com.quiz.runtime/com.quiz.shared-assetspackage split that this page's table mirrors. - Per-App Scaffolding — work explicitly excepted from TDD per the section above.
- Build Plan — Definition of Done references this page rather than restating tests per item.
AI Tooling
The project uses AI tooling throughout the development workflow to accelerate implementation, surface issues early, and keep code quality high.
Tools
| Tool | Role |
|---|---|
| Claude Code | Primary AI coding assistant. Drives feature implementation, refactoring, code review, and knowledgebase maintenance. |
| OpenCode | Secondary AI coding assistant. Complements Claude Code on tasks where a different model behaviour is preferred or when running in parallel on independent work. |
| Self-hosted Qwen3.5 LLM | Local fallback for both Claude Code and OpenCode when usage limits are hit on the primary providers. Keeps the team unblocked without paying for additional cloud-LLM throughput. |
No other AI providers are part of the project's tooling.
How AI tooling is used
- Feature implementation. Engineers describe a build-plan item or user story to the AI assistant; the assistant reads the surrounding code, drafts the change, and applies it. The engineer reviews the diff, runs the tests, and merges through the standard PR flow.
- Knowledgebase maintenance. Spec changes, decision capture, and design notes are written through the AI assistant against the knowledgebase so the docs stay aligned with the code.
- Code review. AI-driven review passes are run on every PR alongside human review — the assistant flags issues a human reviewer might miss (consistency, naming, dead code, untested branches).
- Local automation. AI assistants drive the project's editor MCPs (Unity, etc.) so editor-level operations (creating prefabs, wiring scenes, running tests) can be scripted from a chat interface rather than clicked through manually.
- Failover to self-hosted Qwen3.5. When Claude Code or OpenCode hit their rate limits, the assistant transparently falls back to the project's Qwen3.5 instance running on internal hardware. Capability is reduced relative to the cloud models, but the team remains unblocked for the duration of the limit.
Conventions
- AI-generated changes are always reviewed by a human before merge — same standard as any other code change. Definition of Done is the same regardless of who (or what) wrote the code.
- AI-driven docs changes go through the same git + PR review as code changes; the auto-generated PRD / Build Plan / Design Spec outputs are derived artefacts and not edited directly (see the
docsskill). - Test discipline (Testing) applies to AI-written code identically: failing test first, end-to-end test for user-facing features, lint + format pass.
The detailed multi-editor routing protocol the AI tooling uses to drive the four open Unity Editors (Host, Client, Remote, Quiz.Preview) lives in the team-facing unity-mcp.md page; that level of detail is operational, not requirements-level, and lives in the knowledgebase rather than the PRD.
Per-App Scaffolding
The on-disk shape each Unity project converges on. Covers Host, Client, Remote, and the Quiz.Preview render-only project. The Designer is a separate Tauri 2 + Angular workspace — see Designer Shell and Repository Layout.
com.wildfiregames.core (third-party UPM) provides the underlying Registry, GameBehaviour, SceneReference, audio system, event queue, pooling, settings, and entity infrastructure every Unity project depends on. com.quiz.runtime (the local UPM package described in Repository Layout) holds the Quiz-domain Unity adapters: object-type registry implementation, slide/element MonoBehaviour scaffolding, networking glue. Game-loop bootstrap (PersistentSystems, SaveSystem, AudioController, scene loaders) lives per-app because each app's needs diverge quickly.
Universal conventions
Apply to all four Unity projects (Host, Client, Remote, Quiz.Preview).
_Game/is the per-app root underAssets/. Three sibling folders:Entities/,Modules/,Scenes/. Plus_Global/for cross-scene assets (audio, fonts, sprites, configuration, input actions).- One asmdef per app, named
Quiz.{App}.Game.asmdef, root namespaceQuiz.{App}.Game. companyName:QuizCompany.productName:Quiz.{App}.- Active Input Handling: Input System Package (or "Both" if a fallback to legacy is needed). Input asset at
_Global/Configuration/InputActions.inputactions. - Persistent prefab:
_Game/Scenes/_Common/Resources/PersistentSystems.prefab— auto-loaded at startup, holds the persistent systems for that app (audio, save, etc.). Singleton; one instance ever. - Empty scene:
Scenes/Empty/Empty.unity— used as a transient scene during cross-scene swaps. - Setup scene:
Scenes/Setup/Setup.unity— the cold-boot bootstrapper. Host/Client/Remote use it as an invisible bootstrap that loads persistent systems then transitions to the main menu. Quiz.Preview replaces it with the WebGL boot scene.
The Designer (Tauri 2 + Angular) does not converge on this Unity scaffolding shape — its workspace layout is in Repository Layout — Designer project graph.
Quiz.Preview designer
A Unity project, sibling to Host / Client / Remote, built only for WebGL — see Designer Shell.
Scenes:
- Preview/Preview.unity — single render scene with the slide canvas + camera; entry point for the WebGL boot.
Per-scene scripts:
- Preview/Scripts/PreviewBoot.cs — listens for JS messages (LoadSlide, UpdateProperty, SetSelection); calls back via [DllImport("__Internal")] for ready / rendered / error events.
References: com.quiz.shared-assets, com.quiz.runtime, com.quiz.core via Packages/manifest.json.
Project Settings:
- WebGL build target only.
- defaultScreenWidth/Height flexible (canvas size driven by the host page).
- WebGLMemorySize tuned for the largest expected scene.
Host host
The live-quiz runtime — renders the quiz to a TV / projector and runs the in-process WebSocket server. Two top-level scenes:
Scenes/MainMenu/MainMenu.unity— pre-quiz lobby: load a.quiz, accept Designer transfers, list connected teams, start session.Scenes/Play/Play.unity— the slide-driven runner that advances through the quiz, dispatches element behaviours, and runs the live session.
Per-scene scripts and prefabs:
- MainMenu/Scripts/MainMenuViewController.cs — drives the lobby UI.
- Play/Scripts/PlayController.cs — orchestrates slide advance, element runtime, scoring, and session state.
- _Common/Resources/PersistentSystems.prefab and _Common/Scripts/PersistentSystems/* — audio bus + persistent loader.
- _Common/Scripts/SaveSystem/* — session-state persistence per Networking — Crash recovery.
References: com.quiz.shared-assets, com.quiz.runtime, com.quiz.core.
Project Settings:
- defaultScreenOrientation: 3 (LandscapeLeft); portrait autorotates disabled.
- defaultScreenWidth: 1920, defaultScreenHeight: 1080 (TV / projector target).
Client client
The team-device app — joins a session, renders each slide's Client canvas, collects answers. Two top-level scenes:
Scenes/MainMenu/MainMenu.unity— discovery + join: list visible Hosts, enter team name, join.Scenes/Play/Play.unity— render the current slide's Client canvas, dispatch input elements, show score.
Per-scene scripts and prefabs:
- MainMenu/Scripts/MainMenuViewController.cs — discovery + join UI.
- Play/Scripts/PlayController.cs — slide rendering, input dispatch, score display.
- _Common/Resources/PersistentSystems.prefab + scripts — audio + persistent loader.
- _Common/Scripts/SaveSystem/* — team-identity persistence per F-CL-10.
References: com.quiz.shared-assets, com.quiz.runtime, com.quiz.core.
Project Settings:
- defaultScreenOrientation: 4 (AutoRotation) — Client supports portrait and landscape on phones and tablets.
Remote remote
The quizmaster's pocket controller — pairs with a Host, mirrors its display, sends control commands. Two top-level scenes:
Scenes/MainMenu/MainMenu.unity— discovery + pairing: list visible Hosts, pair (manual confirm or QR), connect.Scenes/Play/Play.unity— render the mirrored Host canvas + host-notes + live state, send control commands.
Per-scene scripts and prefabs:
- MainMenu/Scripts/MainMenuViewController.cs — discovery + pairing UI.
- Play/Scripts/PlayController.cs — mirror rendering, host-notes display, live-state display, command dispatch.
- _Common/Resources/PersistentSystems.prefab + scripts — audio + persistent loader.
References: com.quiz.shared-assets, com.quiz.runtime, com.quiz.core.
Project Settings:
- defaultScreenOrientation: 1 (Portrait); landscape autorotate disabled, portrait autorotate enabled.
Unity MCP — Multi-Editor Routing
How an MCP client (Claude Code, OpenCode, etc.) targets the correct Unity Editor when all four app projects — Quiz.Preview, Quiz.Host, Quiz.Client, Quiz.Remote — are open simultaneously. The four-sibling-project layout is described in Repository Layout; this page is the runtime tooling that sits on top of it.
This page exists primarily as a safety contract: an AI assistant editing scripts, scenes, or prefabs in the wrong editor will silently mutate the wrong app's Assets/ tree. Every Unity MCP tool call must be routed deliberately.
Topology
- One Coplay
com.coplaydev.unity-mcppackage, pinned viaPackages/manifest.jsonin all four Unity projects (the manifests are identical). - One Python MCP server,
uvx --from mcpforunityserver mcp-for-unity, registered in.mcp.jsonat the repo root under server nameUnityMCP. Stdio transport. The server is shared across all four editors — there is not one server per editor. - Each open Unity Editor opens a TCP listener on
127.0.0.1, claiming the first free port in 6400–6499. With all four editors open the typical assignment is Preview → 6400, Host → 6401, Client → 6402, Remote → 6403, but ports can shift if an editor is closed and reopened. - Each editor writes a heartbeat file at
~/.unity-mcp/unity-mcp-status-<projectHash>.jsoncontainingunity_port,project_path,project_name,unity_version.projectHashis a SHA overApplication.dataPath— stable while the project stays at its current absolute path, changes if the project moves on disk. - The MCP server discovers editors by scanning that directory; the client picks one via the
mcpforunity://instancesresource and theset_active_instancetool.
Routing protocol
Run this sequence before every Unity tool call, not just at session start:
- Read
mcpforunity://instances(resource on theUnityMCPserver). Returns a JSON list of live editors withid(Name@hash),name(project_name),path,hash,port,status,last_heartbeat,unity_version. - Map by
project_name, never by port. The user names an app (preview / host / client / remote); match it to one of the literal stringsQuiz.Preview,Quiz.Host,Quiz.Client,Quiz.Remote. Ports are not stable identifiers — they auto-allocate on editor start and may differ from the table above. - Call
set_active_instancewith the matchedidtoken (e.g.Quiz.Host@9f9e4a06). All subsequent Unity tool calls in this session route to that editor until the nextset_active_instance. - Re-route on every app switch. If the conversation shifts from Host work to Client work, call
set_active_instanceagain with the Client token before the first Client-targeted tool call. Never assume the previously active editor is still correct.
For a single one-off call against a non-active editor, pass unity_instance="Name@hash" (or "hash", or "port" in stdio mode) as a tool argument instead — this routes that call only without changing the session default.
Hard rules
- Never cache tokens across sessions.
Application.dataPathis absolute, so the SHA hash changes if the repo is cloned to a different path or moved. Always re-readmcpforunity://instancesat the start of any Unity work. - Never guess a port. If a needed editor is missing from
mcpforunity://instances, stop and ask the user to open it (and verifyWindow → MCP for Unityshows green) — do not try a port number from this page or from a previous session. - Never act on "the Unity project" without an explicit app target. Four editors are running; the request is ambiguous. Ask which of the four before routing.
- Never use the active editor as a fallback. If the requested app's editor isn't running, the answer is "open the editor", not "use whichever is currently active".
Why per-editor and not per-server
A natural alternative would be one MCP server entry per Unity project — UnityMCP.Designer, UnityMCP.Host, UnityMCP.Client, UnityMCP.Remote, each pinned to a fixed port. The Coplay package does not support that model: ports are auto-allocated by the editor itself (the package scans 6400–6499 for a free port), and the server is designed to multiplex via heartbeat discovery. Hard-coding ports at the client side would race the editor's port-allocation logic and break the moment any editor restarted into a different port. The heartbeat-and-token model is the only routing scheme the package supports.
Operational checks
mcpforunity://instancesreturns four entries with the four expectedproject_namevalues,status: "running", recentlast_heartbeat. Anything less means an editor is closed or its bridge is not running.Window → MCP for Unityinside each editor shows green and the same port the heartbeat reports..mcp.jsonat the repo root has theUnityMCPentry. Restart the MCP client after any change.
Key Architectural Decisions and Tradeoffs
Load-bearing decisions, with the rationale that justifies each. Decisions are recorded so they are not relitigated without a good reason.
Unity for live-play apps (Host, Client, Remote); Tauri 2 + Angular for the Designer shell, with an embedded Unity WebGL preview canvas. Unity gives a single engine for live-play visuals (animation, particle, shader, 2D and 3D) and unifies the build/CI shape across the three live-play apps. The Designer is forms-heavy authoring (slide list, palette, properties inspector, modal panels, menus) plus a slide preview — concerns where Angular's drag-drop / overlay / virtual-scroll / tree CDK primitives plus Angular Material's form components are best-in-class for tool UIs. Tauri 2's tiny Rust shell + system WebView delivers native window chrome (Win 11 Mica, macOS traffic-lights inset), real OS-level multi-window, native menus, native file dialogs, and subprocess spawn — all the integration the desktop authoring surface needs, without bundling Chromium. A PlatformAdapter interface keeps the eventual Web Designer Stretch additive: the Angular component layer is shell-agnostic, the Tauri-specific surface is reached only through the adapter. See Tech Stack and Designer Shell.
Slide preview is a dedicated Quiz.Preview Unity project, WebGL-only, embedded in the Designer's main WebView. Quiz.Preview is a sibling Unity project to Quiz.Host / Quiz.Client / Quiz.Remote that builds only for WebGL and serves as the slide-render target for the Designer. It shares scenes, prefabs, materials, and rendering setup with Host / Client via the com.quiz.shared-assets UPM package — visual output stays aligned across the four Unity projects without forking the asset tree. Angular pushes slide state to Quiz.Preview over an in-WebView JS bridge (unityInstance.SendMessage); the WebGL build emits ready / rendered / error events back via [DllImport("__Internal")] callbacks that Angular subscribes to. Two Unity build targets across the broader product: native (Host / Client / Remote) and WebGL (Preview). The WebGL choice means the same bundle slots into the future Web Designer Stretch without rework — that reuse is a bonus, not the load-bearing reason; the primary reason is one preview integration that works inside any WebView with no platform-specific embedding code.
Pixel-accuracy is ~95-98%. WebGL renders differ from native D3D11 / Metal / Vulkan in shadows, AA, and post-processing. Accepted trade-off for one preview integration with no platform-specific embedding. The PowerPoint-style Run from slide action (F-DE-27) launches the native Host binary as a subprocess so the author can validate the ground-truth render whenever the preview isn't enough.
The Designer ships desktop only in v1 — Windows + macOS — single Tauri + Angular codebase. Browser-hosted authoring is a future Web Designer Stretch; iPad / Android tablet authoring is a further Stretch derived from it. v1 Designer focus is the deepest authoring workflow on desktop (multi-window, real OS chrome, file pickers, drag-drop, subprocess Host launch) without compromising for the touch or browser surfaces.
Schema-first contract between the Designer and the Unity apps; no shared runtime library across the language boundary. The Designer is TypeScript (Angular); Host / Client / Remote are C# (Unity). Both sides build against the same JSON Schema definitions in schemas/ — C# types regenerate into com.quiz.core for the Unity apps, TypeScript types and runtime validators regenerate into the Angular codebase. Designer business logic (quiz model manipulation, undo / redo, validation, push orchestration) is implemented natively in TypeScript inside Angular services. Some semantic rules that can't be expressed as schema constraints (cross-element invariants, custom object-type validators) are implemented twice — once in TS for author-time hints, once in C# for Host runtime enforcement — and both sides assert against shared fixture sets. This is the load-bearing decision under "Angular for Designer": it trades a small amount of duplicated semantic logic for hard language-boundary decoupling. The pure-C# Unity-app side of this picture lives in com.quiz.core per Repository Layout; the pure-C# / Unity-aware boundary on the Unity side is in Separation of Concerns.
Server embedded in the Host (not a separate process). Simpler deployment, fewer moving parts. The Host always operates in a foregrounded, user-attended state during a quiz, which is the state in which all target platforms (iPad, Windows, macOS, Android tablet) can reliably run an in-process server. The same server handles three message families: Designer transfer, Client live-play, and Remote control. Unity scripts run on the main thread, so the WebSocket server runs on a background thread and dispatches state changes back via Unity's main-thread synchronisation primitives. See Networking.
Slide-based authoring with two independent canvases per slide. A quiz is an ordered list of slides, each with a Host canvas (TV-format, fixed 1920×1080 virtual) and a Client canvas (phone/tablet, responsive). The two canvases share the slide's identity, timing, and scoring but can hold completely different elements — the question text might fill the Host canvas while the Client canvas shows tappable answer options. The split lets the same quiz be a "TV show" experience on the projected display and a snug, one-handed phone experience for participants, without the author maintaining two parallel quizzes.
Object-type plugin model is first-class from v1. The schema is modular: new element types can be added without changing core code. This shapes the Quiz Package Format (manifest declares object types by id/version), the shared class library (defines the plugin contract; ships no concrete object-type implementations), and the apps (each maintains a built-in registry that types register themselves into on startup). For v1, every object type a package uses must already be present as a built-in on every app loading the package; bundle-supplied object types are deferred to a stretch goal. See Object-Type Architecture.
v1 is fully local — cloud-backed authoring is a stretch goal. v1 has no auth, no account, no cloud library. The Designer exports .quiz files to disk and sends them to the Host over the local network via a UI action; the Host loads packages from disk. The architecture stays compatible with a future cloud path so adding it later is additive, not a rewrite. Cloud vendor (managed Postgres + S3-compatible object storage) is decided when the stretch goal is promoted. See Backend Schema.
Multi-tenant in the eventual cloud schema, even though authoring starts single-user. Designing for multi-tenancy when the cloud path lands is cheaper than retrofitting it.
v1 .quiz packages contain no runtime C# code. Packages are data only (manifest, slides, resources). Every object type a quiz uses must already be a built-in on the Host and Client, version-matched. Avoids the security/sandboxing/signing surface of loading bundled C# at runtime. Bundle-supplied object types are deferred to the cloud-backed-authoring stretch goal.
Host eagerly distributes per-Client slide content at join time. When a Client connects to a session, the Host pushes the full set of Client-canvas content and Client-side resources for the quiz over the WebSocket. Per-slide play then issues only "show slide N" commands — no per-slide network round-trips, no stutter waiting for media to download mid-display. The trade-off is a larger payload at join (typically tens of MB for a media-heavy quiz, well within local Wi-Fi bandwidth), which is the right side of the trade for a single-room quiz format.
Local network is the primary live-play transport, not a fallback. Once a quiz is on the Host, no internet is required. Hard requirement because pub Wi-Fi cannot be relied on. Internet-based live play is a future, optional transport — not a substitute for the local-network primary path.
Host persists session state to disk; crash recovery is a v1 requirement, not Stretch. The Host writes a session snapshot (current slide, team list, scores, slide-element state) after every scoring event and every slide advance. If the Host process crashes, the operator relaunches the Host with the same .quiz and resumes the session — Clients reconnect using their persisted team identity and re-attach as the same team. The cost is small (single JSON file, frequent small writes) and the outcome is materially better operational behaviour for what is a live, time-pressured event. Alpha-phase deliverable.
The Remote app is a fourth distinct app, not a mode of the Client. A quizmaster walking the room with a controller is a different ergonomic problem from a team submitting answers. Folding both into one app conflates roles, complicates pairing/auth, and makes the per-slide UI compromise (control panel vs answer surface). Separate apps keep each role's UI clean and the message families distinct on the wire.
3D content is a first-class option from day one. Unity makes 3D scenes and effects native rather than bolt-on. Whether any v1 object type uses 3D is a content decision driven by the built-in object-type catalogue, not a platform constraint.
Requirements
Functional Requirements
Numbered functional requirements per app, plus cross-cutting requirements. Per-app responsibilities and platforms are in Applications; per-app UI entry-point inventory in UI Surfaces; non-functional targets are in Non-Functional Requirements. Each requirement is tagged with its target Phase.
Phase tags: MVP, Alpha, Beta, Production, Stretch. The Stretch tag means out-of-scope for the initial launch (v1) and tied to features in Stretch.
Designer designer
- F-DE-1 Stretch The author can sign in to their cloud account.
- F-DE-2 MVP The author can create a new quiz with title, description, and tags.
- F-DE-3 MVP The author can add, edit, reorder, and delete slides within a quiz.
- F-DE-4 MVP The author can group contiguous slides into rounds (sections) with a round title and round-level scoring metadata, and can ungroup or reorder rounds.
- F-DE-5 MVP The author can place, position, configure, reorder, and delete elements on a slide's Host canvas (fixed 1920×1080 virtual canvas).
- F-DE-6 MVP The author can place, configure, reorder, and delete elements on a slide's Client canvas (responsive layout — anchors / regions / stacks).
- F-DE-7 MVP The author can browse the available object-type palette (the v1 built-in catalogue) and add an instance of any object type to either canvas. The palette grows as object types are introduced across phases.
- F-DE-8 Alpha The author can attach images, audio, and video to a quiz as resources, and reference them from element properties. Media object types (
core.image,core.audio-clip,core.video) land in Alpha; MVP supports the resource-attachment plumbing only insofar as the.quizpackage format is canonical. - F-DE-9 MVP The author can configure scoring rules per slide and per round (point values, time bonuses, late-submission penalties).
- F-DE-10 MVP The author can configure timing per slide (and defaults per round): slide duration, whether time-up locks Client input, whether late submissions are accepted, the scoring rule for late submissions (none / fixed penalty / per-second decay), and whether the quizmaster can override the timer live.
- F-DE-11 MVP The author can preview a slide's Host and Client canvases live in the embedded Unity preview surface as they edit.
- F-DE-12 MVP The author can save a quiz to the local file system as a
.quizfile. - F-DE-13 MVP The author can re-open an existing
.quizfile from the local file system. - F-DE-14 MVP The Designer can discover Hosts on the local Wi-Fi network (via Bonjour/mDNS) and send a saved
.quizfile to a chosen Host over the local network. - F-DE-15 Stretch The author can save a quiz to the cloud, creating a new version, and view/restore version history.
- F-DE-16 Stretch The author can soft-delete a quiz and recover it from a "trash" view within 30 days.
- F-DE-17 Stretch The author can use stylus input for sketching and annotation where useful (Apple Pencil on iPad; equivalent stylus on supported non-Apple tablets). v1 is touch-only on every platform — drawing inputs accept finger touch; pressure/tilt/palm-rejection is the stretch addition.
- F-DE-18 MVP The
.quizpackage format is canonical: the manifest declares every object type used and its required schema version. v1 packages contain no runtime code. - F-DE-19 MVP The author can attach host-notes to each slide — free-form text authored in a PowerPoint-style notes pane below the canvas in the Designer (always visible while editing a slide). Notes accept either plain text or markdown; markdown is rendered at runtime. Notes appear on the Remote and on the Host operator window (per F-HO-25) during play — never on the audience-facing Host main display and never on any Client. Used for hints, answer keys, presentation cues.
- F-DE-20 MVP The author can configure each placed element's reveal trigger (on slide entry / on quizmaster trigger / after delay) and reveal animation. The reveal field is part of every element on every slide.
- F-DE-21 Beta The author can define the per-team theming options for the quiz: a palette of team colours and a set of avatars Clients can pick from at join. See Beta — Per-team theming.
- F-DE-22 Stretch The author can request AI-generated question suggestions on a topic (multiple-choice options, distractors, host-notes drafts). Generated content is staged for the author's review and explicit confirmation before it lands in a slide. See Stretch — AI-aided quiz authoring.
- F-DE-23 Stretch The author can clone a quiz template from the cloud library as the starting point for a new quiz. Depends on cloud-backed authoring.
- F-DE-24 Stretch The author can build a personal question bank — questions decoupled from any specific quiz — and pull questions from the bank into one or more quizzes. Depends on cloud-backed authoring.
- F-DE-25 Beta The Designer chrome ships dark and light themes. The author picks one as their working theme; the choice is persisted in app settings and is independent of the per-quiz theme an authored quiz declares.
- F-DE-26 Beta The author can declare a theme for the quiz in the manifest (
dark,light, or brand preset). The Host, Client, and Remote render the quiz against this theme during play. - F-DE-27 MVP The author can run the current quiz starting at the currently-selected slide from the Designer (toolbar "▸ Run" CTA or
F5). Activation is fire-and-spawn with no confirmation dialog — the Designer immediately spawns a local Host process on the same machine, passing the in-memory.quiz(or its on-disk path) and a start-at-slide pointer. The spawned Host registers an Esc-key handler that quits the runner and returns focus to the Designer; the Designer process remains alive throughout. Used for quick author-side validation without going through the network push-to-Host flow (F-DE-14). Push-to-Host stays as the network-discovery path; run-current-slide is local-machine-only. - F-DE-28 Alpha The author can attach buzzer jingles to a quiz — short audio clips bundled inside the
.quizunderresources/audio/buzzers/and listed in the manifest'sbuzzers[]array. These are the clips a team can pick from at join (F-CL-15) and the Host plays when that team submits a buzzer answer (F-HO-27). The bundling avoids music-licensing exposure on the app — the author owns the clips they ship. Required asset shape: short (≤ 4 s recommended, ≤ 8 s hard cap), mono or stereo, MP3 / OGG / WAV. Depends on the.quizpackage format gaining theresources/audio/buzzers/bucket + manifest field. - F-DE-29 Alpha The Designer ships a built-in default jingle library — a small curated set of generic buzzer clips covering the common "ding" / "buzz" / "horn" / "fanfare" archetypes. The author browses the library in the Designer and adds chosen clips to the active quiz; the selected clips are copied into the
.quizpackage'sresources/audio/buzzers/folder (not referenced by id from the Designer install), so the package stays self-contained when transferred to a Host that doesn't have the Designer installed. Built-in clips are pre-licensed for distribution as part of the quiz. - F-DE-30 Alpha The author can attach a premade avatar set to the quiz — image files bundled under
resources/avatars/and listed in the manifest'savatars[]array. Teams pick from this set at join if they don't want to use a photo (F-CL-14). The Designer's default library also ships a small starter avatar set the author can drop in, with the same self-contained-bundle rule as buzzer jingles (F-DE-29).
Host host
- F-HO-1 Stretch The host operator can sign in to their cloud account.
- F-HO-2 Stretch The host can list and download quizzes from the operator's cloud library.
- F-HO-3 MVP The host runs an in-process WebSocket server and advertises itself on the local network via Bonjour/mDNS — handling Designer transfers (when idle), live Client connections (during a session), and Remote control connections.
- F-HO-4 MVP The host can receive a
.quizfile pushed over the local network from a Designer. Gating: the Host shows a manual-confirm prompt for every incoming transfer, identifying the originating Designer and the file size. The Host validates the manifest on receipt and stores the file locally only if the operator accepts. - F-HO-5 MVP The host can load a
.quizfile from the local file system (manual file picker, for files transferred outside the Designer push flow). - F-HO-6 MVP Loaded quiz packages remain available offline.
- F-HO-7 MVP The host resolves every object type referenced by a loaded package against its built-in registry and refuses the package if any are missing or version-incompatible.
- F-HO-8 MVP The host can start a quiz session from a loaded package.
- F-HO-9 MVP The host displays a join screen showing connected teams (one Client device per team) and their team names.
- F-HO-10 MVP When a Client connects, the host eagerly pushes the slides' Client-canvas content and Client-side resources for the entire quiz over the WebSocket.
- F-HO-11 MVP The host advances through the quiz's slides in order, rendering each slide's Host canvas at the connected display's resolution and dispatching its element behaviours; "show slide N" commands are issued to Clients without further resource transfer.
- F-HO-12 Alpha The host plays attached media (images, audio, video) at the appropriate moment via the relevant element behaviours. Depends on the media object types added in Alpha.
- F-HO-13 Alpha→Beta The host runs interactive moments — animated reveals, transitions, mini-game presentation overlays — driven by element behaviours on the Host canvas. First-pass animation in Alpha; brand-true motion language and mini-game overlays in Beta.
- F-HO-14 MVP The host receives answers from clients (via element protocol extensions), applies scoring rules (including late-submission rules per F-DE-10), and displays the leaderboard when the slide places a
core.leaderboardelement with a triggered reveal. - F-HO-15 MVP The host handles client disconnects and reconnects gracefully (a Client device backgrounding does not crash the session); a reconnecting client may re-receive the eager push or a delta.
- F-HO-16 MVP The host is authoritative on time. It emits a periodic "time-remaining" tick and an authoritative "lock" message at time-up; Clients reconcile against this.
- F-HO-17 MVP The host operator can override the timer live for the current slide: extend, skip, manually lock, manually unlock. Override via the Remote is part of the Alpha rich-command set — see F-HO-24 and F-RE-9.
- F-HO-18 Alpha The host persists session state to disk after every scoring event and every slide advance — sufficient to resume the session if the Host process crashes and is relaunched with the same
.quizloaded. - F-HO-19 Alpha On launch with a saved session that matches the loaded
.quiz, the host prompts the operator to resume or start fresh. Resume restores the slide pointer, team list, scores, and per-element state; the host re-advertises on Bonjour and accepts reconnecting Clients as their original teams. - F-HO-20 MVP The host accepts a paired Remote over the WebSocket on a control message family. Pairing offers both options simultaneously: the Host displays a short numeric pairing code and a QR encoding the same code. The Remote can type the code manually or scan. Resolved per Open Questions #9.
- F-HO-21 MVP The host streams a periodic mirror of its current display, the current slide's host-notes, and live state (scores, timer remaining, slide index) to the paired Remote, and accepts the core navigation commands (advance, go-back) from it. The richer control-command set is F-HO-24.
- F-HO-22 Stretch The host offers a broadcast / streaming-friendly display mode with chrome and styling tuned for screen-capture: subdued backgrounds, boosted overlay readability, no in-room-only prompts. See Stretch — Broadcast view.
- F-HO-23 Stretch The host recognises returning teams (from previous sessions for the same quizmaster account) and offers them their previous identity at join. Depends on cloud auth + recurring-teams identity store.
- F-HO-24 Alpha The host accepts the rich control command set from the paired Remote, on top of the MVP advance/go-back: jump to a specific slide, trigger element reveals, lock/unlock Client input, extend/skip the timer, override scoring per team. Mirror of F-RE-9.
- F-HO-25 MVP The host supports a dual-window operator view on machines with multiple displays. The author / operator picks which connected screen renders the audience-facing main display (clean slide canvas, F-HO-11) and the Host opens a second OS-level window on another screen as the operator window — a same-machine equivalent of the Remote: live mirror of the audience screen, current slide's host-notes (F-DE-19), session HUD (timer, scores, slide index), and the same nav + control affordances the Remote exposes (F-HO-21, F-HO-24 at Alpha). On a single-display machine the operator window can be summoned as an overlay or skipped; the audience canvas is the only required surface.
- F-HO-26 Alpha The host renders the team photo (or fallback premade avatar) supplied by each team at join (F-CL-14) on the leaderboard rows, the join screen team roster, and any per-team callout (correct-answer ping, big-reveal celebration). Photos are stored in memory + the session-snapshot file for crash recovery; cleared at session end. No long-term persistence in MVP/Alpha (cloud sync of team identity is Stretch — F-HO-23).
- F-HO-27 Alpha The host plays the per-team buzzer jingle (chosen at join per F-CL-15) on its audio output the moment that team's buzzer-press wins the first-press race. If a team did not pick a jingle the Host plays a generic silent-buzz / haptic cue instead. Volume is governed by the Host's audio settings; the operator can mute jingles globally without ending the session.
Client client
- F-CL-1 MVP The client discovers active hosts on the local Wi-Fi network via Bonjour/mDNS.
- F-CL-2 MVP The client allows a team to enter a team name and join a host. v1 is one shared Client device per team; there is no per-individual identity within a team.
- F-CL-3 MVP On joining, the client receives the eagerly-pushed slide content and resources for the whole quiz from the host and caches them in memory. A late-joining Client shows a progress UI during the eager push and, on completion, jumps directly to the slide the quiz is currently on.
- F-CL-4 MVP The client resolves every object type referenced by a slide against its built-in registry and reports a fatal mismatch to the host if any are missing or version-incompatible.
- F-CL-5 MVP The client renders the current slide's Client canvas with its responsive layout adapting to the device's form factor, dispatching each element's runtime behaviour.
- F-CL-6 MVP→Alpha The client supports input modalities exposed by the relevant object types. MVP: multiple-choice tap, free-text entry. Alpha: drawing, gesture (buzzer).
- F-CL-7 Beta The client runs mini-games when a slide includes a mini-game element. Depends on the mini-game framework added in Beta.
- F-CL-8 MVP The client displays the team's score and standings.
- F-CL-9 MVP The client gracefully handles disconnection and reconnection.
- F-CL-10 Alpha The client persists its team identity (a stable token issued by the Host on first join) to local storage so it can rejoin a recovered session as the same team without re-entering the team name.
- F-CL-11 MVP The client renders the Host's authoritative timer state (countdown / locked / extended / skipped) and stops accepting input when the Host emits a "lock" message.
- F-CL-12 Beta At join, the team picks a colour and an avatar from the author-defined palette; the choice carries through every Client surface and every per-team Host moment (leaderboard rows, team-callouts).
- F-CL-13 Stretch A returning team can reclaim their previous identity (name, colour, avatar) when joining a session run by the same quizmaster. Depends on cloud auth + recurring-teams identity store.
- F-CL-14 Alpha At join, the team can take a photo with the device camera (Unity
WebCamTextureAPI — single cross-platform path, see Tech Stack) or pick from the quiz's bundled premade-avatar set per F-DE-30. The captured photo is centre-cropped to square, bilinear-downscaled to 256 × 256 px, JPEG-encoded at quality 75 on the Client; if the encoded size exceeds the 96 KB hard cap, the Client re-encodes at quality 60 before sending. The encoded JPEG is uploaded to the Host as part of the join message (Networking — Team join). The Host renders the photo on the leaderboard and any per-team callout (F-HO-26). The Client requests camera permission via the OS; if denied or unavailable the captain falls back to the premade avatar set without an error state. - F-CL-15 Alpha At join, the team picks a buzzer jingle from the set bundled into the loaded quiz (per F-DE-28). The choice is transmitted to the Host in the join message. The Client previews each jingle on tap before commit. If the quiz has no jingles bundled the buzzer-jingle picker is hidden and the Host falls back to a silent buzz tone. Depends on
core.buzzer-input(this phase, Alpha).
Remote remote
The Remote app ships in MVP with minimum viable controls — discovery, pairing, mirror, host-notes, live state, and the core navigation commands (advance / go-back). The richer control-command set lands in Alpha (F-RE-9).
- F-RE-1 MVP The Remote discovers active Hosts on the local Wi-Fi network via Bonjour/mDNS.
- F-RE-2 MVP The Remote pairs with one Host at a time. Pairing is gated (manual confirm on the Host the first time a given Remote connects, or QR-code pairing — final UX is build-plan).
- F-RE-3 MVP The Remote opens a control-message-family WebSocket to the paired Host.
- F-RE-4 MVP The Remote renders a live, scaled-down preview of what the Host's main display is currently showing.
- F-RE-5 MVP The Remote displays the per-slide host-notes (from F-DE-19) for the current slide.
- F-RE-6 MVP The Remote displays live session state: current scores, current timer remaining (the Host's authoritative state), and current slide index within the quiz.
- F-RE-7 MVP The Remote sends core navigation commands to the Host: advance, go-back. Richer control commands are F-RE-9.
- F-RE-8 MVP The Remote handles disconnection and reconnection gracefully (Wi-Fi blip recovery; rejoins the same paired Host without re-pairing).
- F-RE-9 Alpha The Remote sends the rich control command set to the Host on top of the MVP advance/go-back: jump to a specific slide, trigger element reveals (e.g. show the leaderboard, show the answer), lock or unlock Client input, extend or skip the timer, override scoring per team. Host side is F-HO-24.
Cross-cutting project
- F-X-1 MVP A single
schemas/JSON Schema tree is the source of truth for every cross-app data shape (quiz package, WebSocket messages across all message families). Codegen produces C# types intocom.quiz.corefor the Unity apps (Host, Client, Remote, Quiz.Preview) and TypeScript types +ajvvalidators into the Angular Designer, so every app stays in sync. See Repository Layout — Cross-language contract. - F-X-2 MVP Schema versioning: a quiz package declares its schema version, and the Host gracefully refuses or upgrades older packages.
- F-X-3 Beta All apps use a shared design language (typography, color palette, motion vocabulary) — see Design Specification. MVP and Alpha are visually utilitarian; the brand-true treatment lands in Beta.
- F-X-4 Beta Host, Client, and Remote read the theme from the loaded quiz manifest (per F-DE-26) and render against that theme. Default is the dark brand theme.
- F-X-5 Beta All four apps ship a full audio language: branded stings (correct / incorrect / lock / time-up / big reveal / end of round / end of quiz), transition motifs, and a light optional ambient music bed. See Design Specification — sound design.
- F-X-6 Stretch Session-code joining for internet play. When the Internet-relay is reachable, the Host advertises a short alphanumeric session code (e.g.
Q7-3K9-FX). Clients on the discover screen can type that code instead of picking a Host from the Bonjour list, and Remotes do the same on their pairing screen. On the wire the join message is identical between LAN and internet paths; only the transport differs (direct WebSocket on LAN, relay-tunnelled WebSocket via Cloudflare Durable Objects or equivalent in internet mode — see Open Questions #3).
Non-Functional Requirements
| Area | Requirement |
|---|---|
| Reliability | Live quiz sessions must survive client disconnects, the Host backgrounding briefly, and minor Wi-Fi instability. No single client misbehaving should affect other clients or the Host. Crash recovery (Alpha onwards): if the Host process crashes, relaunching the Host with the same .quiz loaded must restore the session — current slide pointer, team list, and scores — and accept reconnecting Clients as their original teams. |
| Performance | UI animations target 60 fps on iPhone 12 / equivalent Android phone or tablet and above; ≥ 60 fps on supported desktops (Windows, macOS). Question transitions complete within 500 ms of trigger on the Host. Client message round-trip target under 200 ms on local Wi-Fi. Remote control message round-trip target under 200 ms on local Wi-Fi. |
| Compatibility | Designer: Windows 10/11, macOS 12+ (desktop only in v1; Tauri 2 + system WebView — WebView2 on Windows, WKWebView on macOS). Host: Windows 10/11, macOS 12+, iPadOS 16+, Android 12+ tablets. Client and Remote: iOS 16+, Android 11+; also runs on iPad and Android tablets. Browser-hosted authoring is Stretch; iPad / Android tablet authoring is a further Stretch. All minimums are provisional — final confirmation against Unity's Editor and Player support matrix (Host / Client / Remote / Quiz.Preview), Tauri 2's supported-platform matrix (Designer), and real-device testing happens in the Beta phase. |
| Quiz scale | Sessions support up to 200 concurrent Client devices (one per team — see Glossary) on local Wi-Fi. Typical session is ~30 teams; the 200-team upper bound covers large-venue and multi-room pub nights, charity events, and league finals. Every team-listing or team-modifying surface — join roster, leaderboard, scoreboards, score-override editor, final standings — must remain usable across the full range (search / filter / pagination / dense rows). |
| Quiz package size | Up to 200 MB per package. See Quiz Package Format. |
| Storage | v1: local-disk only on the Designer and Host (subject to device free space). stretch Cloud quotas: 5 GB per author; version retention: latest 20 versions plus any pinned versions. |
| Privacy | v1: no cloud account, so author data lives only on the Designer device and any Host they push to. Team names are ephemeral and not stored beyond the session. stretch Author data in the cloud is private to their account. |
| Offline operation | v1 is fully local — neither Designer nor Host nor Client requires internet at any point. Designer→Host transfer needs same-LAN connectivity, not internet. stretch Once a cloud quiz package is downloaded to the Host, the entire live experience runs without internet access. |
Measurement plan
Each NFR is paired with how, where, and when it's measured. The build plan's Reliability and performance section (Alpha) wires up the harnesses; Beta re-runs the same harnesses on real older hardware before sign-off.
| NFR | Metric | Method | Hardware | Phase | Gate |
|---|---|---|---|---|---|
| 60 fps animation | Frame time ≤ 16.6 ms, 95th percentile across a 60 s window | Unity Profiler counter sampled in a play-mode test; CI runs the harness on the Mac mini build agent | iPhone 12 baseline (developer's own); Beta re-runs on owner's "old iPhone" + "old Android phone" | Alpha verifies; Beta re-verifies on older hardware | Median + p95 ≤ 16.6 ms; p99 ≤ 25 ms |
| Question transition < 500 ms | Host time from slideAdvance issued → next slide's slideEnter fully rendered |
Instrumented in Quiz.Host's slide runner; aggregated over 100 transitions in a soak test |
Host's reference device — Windows laptop in MVP; Beta adds iPad / Android tablet | Alpha verifies; Beta re-verifies | Mean ≤ 500 ms; max ≤ 1 s |
| Answer-submit round-trip < 200 ms | Time from Client answerSubmit queued → scoreUpdate received |
Instrumented in Quiz.Client; soak test issues 10 submits/s across 10 simulated teams |
Pixel 4a (low-end Android) + iPhone 12 + iPad mini, on a typical pub-grade Wi-Fi router | Alpha verifies on dev Wi-Fi; Beta re-verifies on the venue's hardware | p95 ≤ 200 ms; p99 ≤ 350 ms |
| Remote control round-trip < 200 ms | Time from Remote command queued → Host slideAdvance / timerTick reflecting the command |
Same harness as answer-submit, on the /control family |
Same as answer-submit | Alpha verifies; Beta re-verifies | p95 ≤ 200 ms; p99 ≤ 350 ms |
| 200 concurrent Clients | Session stays nominal — no Host frame drop > 50 ms, no Client disconnect, all submissions scored within latency budget. Soak target: 30 teams (typical), 200 teams (max). | Synthetic load harness in Quiz.Host.Tests spins up N in-process Client connections; runs a scripted 30 min session at 30 and 200 |
Microsoft-hosted CI agent for the synthetic test; manual real-device test of 12 phones in Beta | Alpha synthetic at 30 + 200; Beta real-device on 12 (real-network proxy — full 200 is real-device validated only if a real event provides the bodies) | No disconnects; latency gates above hold at both scales |
| UI scales to 200 teams | Every team-listing surface remains readable, navigable, and modifiable — Host join roster, audience leaderboard, operator scoreboard, score-override editor, final standings, Remote scores detail, Client end-of-quiz standings | Visual review at 30-team and 200-team fixture; affordances present (search / filter / dense rows / pagination / virtualised scroll / "X of Y" total / quick-jump-to-your-team on Client surfaces) | n/a — design review against mockups | MVP design review; Alpha verified against live 30-client load harness; Beta validated against 12-client real-device run | All surfaces usable at 200 teams without truncation or crash |
| Reliability — soak | No Host crash, no leaked memory > 200 MB growth, no dangling connections, every disconnect recoverable | 90 min soak with: disconnect storms (random Client kills every 2-10 s), Wi-Fi flap simulation (network adapter cycle), simulated Host crash + recovery (process kill + relaunch with same .quiz) |
Host on Mac mini agent + real iPhone / Android Client | Alpha gate | Pass on first run; flakes get a bug ticket, not a relax |
| Crash recovery — RTO | Wall-clock from Host process kill → Clients re-attached as their original teams with scores intact | Soak harness above + a recovery timer | Same as soak | Alpha gate | < 30 s with 10 clients reconnecting |
| Quiz package size cap | Largest authorable package | Designer's save dialog rejects > 200 MB | n/a | MVP build-plan | Hard reject in the schema validator (the validator is the gate) |
| Storage (local) | Available disk space the Designer / Host can tolerate before erroring | App surfaces a clear "out of disk" error 1 GB before exhaustion; never silently loses work | n/a — code-level invariant | MVP | Test simulates OutOfDiskSpace exception at each write site |
Test harness ownership
- Unit / play-mode metrics (frame time, transition time):
com.quiz.runtime/Tests/Performance/(Mac mini agent). - Network round-trip metrics:
Quiz.Host.Tests/SoakTests/running an in-process Client harness — no real Wi-Fi for CI; manual Wi-Fi validation per the build plan's Cross-platform validation section. - Crash recovery:
Quiz.Host.Tests/CrashRecovery/— usesProcess.Kill()on a sub-process Host launched from the test driver. - Real-device runs (Beta): manual checklist documented under
docs/test-plans/beta-real-device.md(build-plan item — page to author in Beta).
Provisional minimums
The compatibility row lists OS / device minimums as provisional. The Beta gate "Hardware/OS minimum versions confirmed" turns these into finals after real-device runs. Surprising regressions on older targets (e.g. Android 11 not actually working) raise the floor; positive surprises (iOS 15 still functional) may lower it.
UI surfaces and flows
UI Surfaces
Per-app surface inventories: every UI entry-point (menu item, toolbar button, dialog, panel, keyboard shortcut, context menu), what it leads to, and which mockup covers it. Used to drive mockup coverage — any row without a Mockup reference is a gap. Per-app flow diagrams (inline Mermaid in the *-flows.md sibling pages — see list below) consume these as their authoritative source. Mermaid theme + authoring conventions live in .claude/skills/mermaid-diagrams/SKILL.md.
For each app's behaviour spec see Functional Requirements. For brand / palette / typography see Design Specification. For the apps themselves see Applications.
- Designer surfaces —
*{designer}*— every authoring-shell entry-point and where it leads. - Designer flows —
*{designer}*— mermaid flowcharts of app lifecycle, File menu, authoring loop, Push, Run, Library. - Host surfaces —
*{host}*— every host-app entry-point during idle, session, recovery. - Host flows —
*{host}*— lifecycle, idle / load, join, session controls, fallback overlay, Remote pairing. - Client surfaces —
*{client}*— every client-app entry-point from join to standings. - Client flows —
*{client}*— lifecycle, discover, join + customisation, session. - Remote surfaces —
*{remote}*— every remote-app entry-point from pair to live-control. - Remote flows —
*{remote}*— lifecycle, discover + pair, paired live control.
Matrix columns
Every per-app page uses one big table per surface group with these columns:
| Column | Meaning |
|---|---|
| ID | Stable identifier — <app>.<surface-group>.<slug>. Used by flow diagrams + comments sidecar references. |
| Type | menu · submenu · toolbar · dialog · modal · panel · tab · context-menu · shortcut · gesture · notification · inline. |
| Label | UI text exactly as shown (use — for icon-only). |
| Parent | The surface this lives inside (ID or root). root = top-level chrome / shell. |
| Leads to | Surface ID(s) opened/navigated-to on activation, or inline for stateful toggle, or — for terminal actions. |
| Phase | MVP · Alpha · Beta · Production · Stretch — copied from the matching functional requirement. |
| Req | Functional-requirement ID(s) (F-DE-3, etc.) backing the surface, comma-separated. — if pure chrome. |
| Status | spec · static · interactive · built — see legend below. |
| Mockup | Path to the static mockup, or —. |
Status legend
- spec — described in this matrix; no mockup yet.
- static — covered by a static mockup under
docs/ui-mockups/<app>/. - interactive — promoted into
ui-mockups/<app>/interactive prototype. - built — implemented in app code; matrix kept in sync as state changes.
Designer surfaces designer
Every entry-point in the Designer — menus, toolbars, dialogs, panels, shortcuts, context menus. Drives mockup coverage. See UI Surfaces — matrix columns for column meanings + status legend. Linked from Designer Shell, Applications — Designer, Functional Requirements — Designer, Design Spec, Design Spec — Studio.
Conventions for this page:
- Shortcut IDs map to
Ctrl+Xon Windows /⌘Xon macOS — written asCtrl Xhere for brevity. - "Leads to" of
inline= stateful toggle without surface change (e.g. dirty-flag dot, theme switch). - "Leads to" of
—= terminal action (closes dialog, advances state, but opens no new surface). - A surface ID prefixed
TODO.is not yet specified — fill before mockup work.
1. Shell chrome
The shell wraps every other surface. Lives in main-shell.html.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.chrome.titlebar |
inline | (quiz title · path · dirty-dot) | root | inline | MVP | F-DE-12 | static | main-shell |
designer.chrome.titlebar.dirty |
inline | (magenta dot) | designer.chrome.titlebar |
inline | MVP | F-DE-12 | static | main-shell |
designer.chrome.windowctl.close |
inline | × | root | designer.dialog.quit-unsaved (if dirty) |
MVP | F-DE-12 | static | quit-unsaved |
designer.chrome.windowctl.min |
inline | – | root | inline | MVP | — | static | main-shell |
designer.chrome.windowctl.max |
inline | □ | root | inline | MVP | — | static | main-shell |
designer.chrome.statusbar.autosave |
inline | "Autosaved Xs ago" | root | inline | MVP | F-DE-12 | static | main-shell |
designer.chrome.statusbar.hosts |
inline | "Hosts: N visible" | root | designer.dialog.push-to-host (on click) |
MVP | F-DE-14 | static | main-shell |
designer.chrome.statusbar.library |
inline | "Library: N assets · X GB" | root | designer.panel.library |
Alpha | F-DE-8 | static | main-shell + library-panel |
designer.chrome.statusbar.slide-counter |
inline | "Slide N / M" | root | inline | MVP | — | static | main-shell |
designer.chrome.statusbar.version |
inline | "vX.Y.Z" | root | inline | MVP | — | static | main-shell |
2. Top-level menus
The Designer uses a menu-bar pattern (File · Edit · View · Help) in the toolbar. Menu contents are currently unspecified in mockups — this matrix is the source of truth until each gets a dropdown mockup.
2.1 File menu
Parent for all file-lifecycle commands.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.menu.file |
menu | File | designer.toolbar |
designer.menu.file.* |
MVP | — | static (button) | main-shell |
designer.menu.file.new |
submenu | New quiz · Ctrl N | designer.menu.file |
designer.dialog.new-quiz |
MVP | F-DE-2 | static | new-quiz |
designer.menu.file.open |
submenu | Open… · Ctrl O | designer.menu.file |
OS file picker → main-shell (loaded) | MVP | F-DE-13 | spec | — |
designer.menu.file.open-recent |
submenu | Open recent ▸ | designer.menu.file |
designer.menu.file.open-recent.list |
MVP | F-DE-13 | spec | — |
designer.menu.file.open-recent.list |
submenu | (dynamic list, last 10) | designer.menu.file.open-recent |
main-shell (loaded) | MVP | F-DE-13 | spec | — |
designer.menu.file.save |
submenu | Save · Ctrl S | designer.menu.file |
inline (autosave on; manual save fires status pulse) | MVP | F-DE-12 | spec | — |
designer.menu.file.save-as |
submenu | Save as… · Ctrl Shift S | designer.menu.file |
OS save picker → main-shell | MVP | F-DE-12 | spec | — |
designer.menu.file.push-to-host |
submenu | Push to Host… · Ctrl Shift P | designer.menu.file |
designer.dialog.push-to-host |
MVP | F-DE-14 | static | push-to-host |
designer.menu.file.run-from-slide |
submenu | Run from current slide · F5 | designer.menu.file |
inline (fire-and-spawn local Host process) | MVP | F-DE-27 | spec | — |
designer.menu.file.restore-backup |
submenu | Restore from backup… | designer.menu.file |
designer.dialog.restore-backup |
MVP | F-DE-12 | static | restore-from-backup |
designer.menu.file.import |
submenu | Import quiz package… | designer.menu.file |
OS file picker | Beta | F-DE-18 | spec | — |
designer.menu.file.export |
submenu | Export .quiz… |
designer.menu.file |
OS save picker | MVP | F-DE-12, F-DE-18 | spec | — |
designer.menu.file.preferences |
submenu | Preferences… · Ctrl , | designer.menu.file |
designer.dialog.preferences |
MVP | F-DE-25 | spec | — |
designer.menu.file.signin |
submenu | Sign in to cloud… | designer.menu.file |
TODO.designer.dialog.signin |
Stretch | F-DE-1, F-DE-15 | spec | — |
designer.menu.file.quit |
submenu | Quit · Ctrl Q | designer.menu.file |
designer.dialog.quit-unsaved (if dirty) or — |
MVP | F-DE-12 | static | quit-unsaved |
2.2 Edit menu
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.menu.edit |
menu | Edit | designer.toolbar |
designer.menu.edit.* |
MVP | — | static (button) | main-shell |
designer.menu.edit.undo |
submenu | Undo · Ctrl Z | designer.menu.edit |
inline | MVP | F-DE-3 | static (toolbar mirror) | main-shell |
designer.menu.edit.redo |
submenu | Redo · Ctrl Shift Z | designer.menu.edit |
inline | MVP | F-DE-3 | static (toolbar mirror) | main-shell |
designer.menu.edit.cut |
submenu | Cut · Ctrl X | designer.menu.edit |
inline | MVP | F-DE-5, F-DE-6 | spec | — |
designer.menu.edit.copy |
submenu | Copy · Ctrl C | designer.menu.edit |
inline | MVP | F-DE-5, F-DE-6 | spec | — |
designer.menu.edit.paste |
submenu | Paste · Ctrl V | designer.menu.edit |
inline | MVP | F-DE-5, F-DE-6 | spec | — |
designer.menu.edit.duplicate |
submenu | Duplicate · Ctrl D | designer.menu.edit |
inline | MVP | F-DE-3, F-DE-5 | spec | — |
designer.menu.edit.delete |
submenu | Delete · Del | designer.menu.edit |
inline | MVP | F-DE-3, F-DE-5 | spec | — |
designer.menu.edit.select-all |
submenu | Select all · Ctrl A | designer.menu.edit |
inline (selects all slides or all elements depending on focus) | MVP | F-DE-3 | spec | — |
designer.menu.edit.find |
submenu | Find in quiz… · Ctrl F | designer.menu.edit |
TODO.designer.panel.find |
Beta | — | spec | — |
2.3 View menu
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.menu.view |
menu | View | designer.toolbar |
designer.menu.view.* |
MVP | — | static (button) | main-shell |
designer.menu.view.design-mode |
submenu | Design mode | designer.menu.view |
designer.canvas.modetab.design |
MVP | F-DE-5, F-DE-6 | static (tab mirror) | main-shell |
designer.menu.view.preview-mode |
submenu | Preview mode | designer.menu.view |
designer.canvas.modetab.preview |
MVP | F-DE-11 | static (tab mirror) | main-shell |
designer.menu.view.both-canvases |
submenu | Both canvases | designer.menu.view |
inline (canvas seg → Both) | MVP | F-DE-5, F-DE-6 | static (seg mirror) | main-shell |
designer.menu.view.host-only |
submenu | Host only | designer.menu.view |
inline (canvas seg → Host) | MVP | F-DE-5 | static (seg mirror) | main-shell |
designer.menu.view.client-only |
submenu | Client only | designer.menu.view |
inline (canvas seg → Client) | MVP | F-DE-6 | static (seg mirror) | main-shell |
designer.menu.view.zoom-in |
submenu | Zoom in · Ctrl + | designer.menu.view |
inline | MVP | F-DE-11 | spec | — |
designer.menu.view.zoom-out |
submenu | Zoom out · Ctrl − | designer.menu.view |
inline | MVP | F-DE-11 | spec | — |
designer.menu.view.zoom-fit |
submenu | Fit to window · Ctrl 0 | designer.menu.view |
inline | MVP | F-DE-11 | spec | — |
designer.menu.view.zoom-100 |
submenu | 100% · Ctrl 1 | designer.menu.view |
inline | MVP | F-DE-11 | spec | — |
designer.menu.view.show-rulers |
submenu | Show rulers | designer.menu.view |
inline | Beta | F-DE-5 | spec | — |
designer.menu.view.show-guides |
submenu | Show smart guides | designer.menu.view |
inline | Beta | F-DE-5 | spec | — |
designer.menu.view.show-grid |
submenu | Show grid | designer.menu.view |
inline | Beta | F-DE-5 | spec | — |
designer.menu.view.theme |
submenu | Theme ▸ Dark/Light | designer.menu.view |
inline (theme switch) | Beta | F-DE-25 | static (chip in every mockup) | (all) |
designer.menu.view.toggle-slidelist |
submenu | Slide list | designer.menu.view |
inline (toggle left panel) | MVP | F-DE-3 | spec | — |
designer.menu.view.toggle-inspector |
submenu | Properties / Insert pane | designer.menu.view |
inline (toggle right panel) | MVP | F-DE-5, F-DE-7 | spec | — |
designer.menu.view.toggle-library |
submenu | Library panel | designer.menu.view |
designer.panel.library |
Alpha | F-DE-8 | static (panel) | library-panel |
2.4 Help menu
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.menu.help |
menu | Help | designer.toolbar |
designer.menu.help.* |
MVP | — | static (button) | main-shell |
designer.menu.help.docs |
submenu | Documentation | designer.menu.help |
external browser | MVP | — | spec | — |
designer.menu.help.shortcuts |
submenu | Keyboard shortcuts · Ctrl ? | designer.menu.help |
TODO.designer.dialog.shortcuts |
MVP | — | spec | — |
designer.menu.help.release-notes |
submenu | Release notes | designer.menu.help |
external browser | MVP | — | spec | — |
designer.menu.help.report-issue |
submenu | Report an issue | designer.menu.help |
external browser | MVP | — | spec | — |
designer.menu.help.about |
submenu | About Quiz Designer | designer.menu.help |
TODO.designer.dialog.about |
MVP | — | spec | — |
3. Toolbar
Always-visible buttons sitting below the title bar.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.toolbar |
inline | (menu + actions row) | root | — | MVP | — | static | main-shell |
designer.toolbar.undo |
toolbar | ↺ Undo | designer.toolbar |
inline | MVP | F-DE-3 | static | main-shell |
designer.toolbar.redo |
toolbar | ↻ Redo | designer.toolbar |
inline | MVP | F-DE-3 | static | main-shell |
designer.toolbar.run |
toolbar | (CTA) ▸ Run from slide · F5 | designer.toolbar |
inline (fire-and-spawn local Host process; Esc inside runner returns to Designer) | MVP | F-DE-27 | spec | — |
Push-to-Host is File-menu only — no toolbar CTA. The toolbar CTA slot is reserved for Run from slide, which spawns a local Host process on the same machine for fast author-side validation (F-DE-27).
4. Splash + first-launch flow
Pre-shell window shown at app start. Lives in splash.html and empty-state.html.
The splash doubles as the boot loading screen — Tauri opens it first, the Angular workspace boots inside it (schema codegen check, library index hydration, Quiz.Preview WebGL pre-load, recent-files index read), and the splash fades when the shell is ready. Same pattern as Adobe / JetBrains / Visual Studio splashes. The splash window is brand-locked Showtime and ignores the Studio chrome theme — it renders identically in light and dark modes. The main shell honours the theme, but the splash is always the brand hero.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.splash |
dialog | (logo + version + tagline) | root | designer.empty-state or designer.main-shell (auto-reopen last) |
MVP | — | static | splash |
designer.empty-state |
dialog | (no recent file welcome) | root | designer.dialog.new-quiz / OS file picker / recent list |
MVP | F-DE-2, F-DE-13 | static | empty-state |
designer.empty-state.new |
toolbar | New quiz · Ctrl N | designer.empty-state |
designer.dialog.new-quiz |
MVP | F-DE-2 | static | empty-state |
designer.empty-state.open |
toolbar | Open file… · Ctrl O | designer.empty-state |
OS file picker | MVP | F-DE-13 | static | empty-state |
designer.empty-state.browse-library |
inline | Browse library → | designer.empty-state |
designer.dialog.library-picker |
Stretch | F-DE-15 | static | empty-state + library-picker |
designer.empty-state.recent-row |
inline | (recent file row) | designer.empty-state |
main-shell (loaded) | MVP | F-DE-13 | static | empty-state |
designer.empty-state.recent-showall |
inline | Show all N → | designer.empty-state |
TODO.designer.dialog.recent-all |
MVP | F-DE-13 | spec | — |
5. Dialogs + modals
Always opened on top of main-shell (or splash) and dismissable.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.dialog.new-quiz |
dialog | New quiz | various entry-points | main-shell (loaded) | MVP | F-DE-2 | static | new-quiz |
designer.dialog.push-to-host |
dialog | Push to Host | toolbar / File menu | designer.dialog.push-to-host.transferring |
MVP | F-DE-14 | static | push-to-host |
designer.dialog.push-to-host.transferring |
dialog | Pushing X MB to Host | push-to-host | main-shell (on done) | MVP | F-DE-14 | static | push-to-host-transferring |
designer.dialog.quit-unsaved |
dialog | Save changes to … ? | window-close · File→Quit | Save/Don't/Cancel → exit or main-shell | MVP | F-DE-12 | static | quit-unsaved |
designer.dialog.restore-backup |
dialog | Restore from backup | File menu | main-shell (restored) | MVP | F-DE-12 | static | restore-from-backup |
designer.dialog.library-picker |
dialog | Pick from library | empty-state · library-panel | main-shell (loaded) | Stretch | F-DE-15, F-DE-23 | static | library-picker |
designer.dialog.preferences |
dialog | Preferences | File menu · Ctrl , | inline tabs (Theme · Autosave · Defaults; built to extend) | MVP | F-DE-25 | spec | — |
TODO.designer.dialog.signin |
dialog | Sign in to cloud | File menu · empty-state | main-shell (signed in) | Stretch | F-DE-1 | spec | — |
TODO.designer.dialog.shortcuts |
dialog | Keyboard shortcuts | Help menu | — | MVP | — | spec | — |
TODO.designer.dialog.about |
dialog | About Quiz Designer | Help menu | — | MVP | — | spec | — |
TODO.designer.dialog.recent-all |
dialog | Recent files (all) | empty-state | main-shell (loaded) | MVP | F-DE-13 | spec | — |
TODO.designer.dialog.import-resource |
dialog | Import resource | properties · library panel | inline | Alpha | F-DE-8 | spec | — |
TODO.designer.dialog.ai-suggest |
dialog | AI question suggestions | properties · slide context menu | inline (review + commit) | Stretch | F-DE-22 | spec | — |
6. Slide-list panel (left)
Lives in main-shell.html. State variants: main-shell-slide-selected.html, main-shell-multiselect.html.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.panel.slidelist |
panel | SLIDES (N · M rounds) | designer.main-shell |
— | MVP | F-DE-3, F-DE-4 | static | main-shell |
designer.panel.slidelist.add |
inline | + Add slide | slidelist | inline (new slide; selects it) | MVP | F-DE-3 | static | main-shell |
designer.panel.slidelist.row |
inline | (slide row) | slidelist | inline (select) | MVP | F-DE-3 | static | main-shell |
designer.panel.slidelist.row.context |
context-menu | Right-click slide | slidelist row | designer.menu.slide-context.* |
MVP | F-DE-3 | spec | — |
designer.menu.slide-context.duplicate |
submenu | Duplicate slide · Ctrl D | slide-context | inline | MVP | F-DE-3 | spec | — |
designer.menu.slide-context.move-to-round |
submenu | Move to round ▸ | slide-context | inline | MVP | F-DE-4 | spec | — |
designer.menu.slide-context.new-round-from |
submenu | New round from selection | slide-context | inline | MVP | F-DE-4 | static (multiselect state) | main-shell-multiselect |
designer.menu.slide-context.host-notes |
submenu | Focus notes pane · N | slide-context | designer.pane.notes (focus + caret) |
MVP | F-DE-19 | spec | — |
designer.menu.slide-context.delete |
submenu | Delete slide · Del | slide-context | inline | MVP | F-DE-3 | spec | — |
designer.panel.slidelist.round |
inline | (round header row) | slidelist | inline (collapse / expand) | MVP | F-DE-4 | static | main-shell |
designer.panel.slidelist.round.context |
context-menu | Right-click round | slidelist | designer.menu.round-context.* |
MVP | F-DE-4 | spec | — |
designer.menu.round-context.rename |
submenu | Rename round · F2 | round-context | inline | MVP | F-DE-4 | spec | — |
designer.menu.round-context.score-config |
submenu | Round scoring | round-context | inline (select round → focus right-pane round properties tab) | MVP | F-DE-9 | spec | — |
designer.menu.round-context.ungroup |
submenu | Ungroup round | round-context | inline | MVP | F-DE-4 | spec | — |
designer.menu.round-context.delete |
submenu | Delete round + slides | round-context | inline (confirm) | MVP | F-DE-4 | spec | — |
Slide context-menu order:
Duplicate slide
Move to round ▸
New round from selection (only when N>1 selected)
---
Focus notes pane
---
Delete slide
Round context-menu order:
Rename round
Round scoring (selects the round → focuses right-pane round properties)
---
Ungroup round
Delete round + slides (confirm)
Round scoring is not a modal — selecting the round header swaps the right-pane Properties tab to the round-level fields (point values, time bonuses, late-submission penalties, per F-DE-9). Same pattern as slide-level / element-level Properties.
7. Canvas (centre)
Mode tabs, segmented view, zoom, dual-canvas stage.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.canvas |
inline | (dual canvas stage) | designer.main-shell |
— | MVP | F-DE-11 | static | main-shell |
designer.canvas.modetab.design |
tab | Design | designer.canvas |
inline | MVP | F-DE-5, F-DE-6 | static | main-shell |
designer.canvas.modetab.preview |
tab | Preview | designer.canvas |
inline (live Quiz.Preview WebGL) |
MVP | F-DE-11 | spec | — |
designer.canvas.seg |
inline | Both / Host / Client | designer.canvas |
inline | MVP | F-DE-5, F-DE-6 | static | main-shell |
designer.canvas.zoom |
inline | 100% ▾ | designer.canvas |
dropdown (25/50/75/100/150/200 / fit / fill) | MVP | F-DE-11 | spec | — |
designer.canvas.host |
inline | (Host canvas) | designer.canvas |
inline (selection / drag / resize) | MVP | F-DE-5 | static | main-shell |
designer.canvas.client |
inline | (Client canvas) | designer.canvas |
inline | MVP | F-DE-6 | static | main-shell |
designer.canvas.element |
inline | (placed element) | host / client canvas | inline (select → properties tab) | MVP | F-DE-5, F-DE-6 | static | main-shell-element-selected |
designer.canvas.element.context |
context-menu | Right-click element | canvas element | designer.menu.element-context.* |
MVP | F-DE-5 | spec | — |
designer.menu.element-context.cut |
submenu | Cut · Ctrl X | element-context | inline | MVP | F-DE-5 | spec | — |
designer.menu.element-context.copy |
submenu | Copy · Ctrl C | element-context | inline | MVP | F-DE-5 | spec | — |
designer.menu.element-context.paste |
submenu | Paste · Ctrl V | element-context | inline | MVP | F-DE-5 | spec | — |
designer.menu.element-context.duplicate |
submenu | Duplicate · Ctrl D | element-context | inline | MVP | F-DE-5 | spec | — |
designer.menu.element-context.bring-to-front |
submenu | Bring to front · Ctrl Shift ] | element-context | inline | MVP | F-DE-5 | spec | — |
designer.menu.element-context.bring-forward |
submenu | Bring forward · Ctrl ] | element-context | inline | MVP | F-DE-5 | spec | — |
designer.menu.element-context.send-backward |
submenu | Send backward · Ctrl [ | element-context | inline | MVP | F-DE-5 | spec | — |
designer.menu.element-context.send-to-back |
submenu | Send to back · Ctrl Shift [ | element-context | inline | MVP | F-DE-5 | spec | — |
designer.menu.element-context.copy-to-host |
submenu | Copy to Host canvas | element-context | inline | MVP | F-DE-5, F-DE-6 | spec | — |
designer.menu.element-context.copy-to-client |
submenu | Copy to Client canvas | element-context | inline | MVP | F-DE-5, F-DE-6 | spec | — |
designer.menu.element-context.lock |
submenu | Lock / Unlock | element-context | inline (toggle) | Beta | F-DE-5 | spec | — |
designer.menu.element-context.delete |
submenu | Delete · Del | element-context | inline | MVP | F-DE-5 | spec | — |
Element context-menu order + grouping, top → bottom, dividers shown as ---:
Cut · Copy · Paste · Duplicate
---
Bring to front · Bring forward · Send backward · Send to back
---
Copy to Host canvas · Copy to Client canvas
---
Lock / Unlock (Beta — toggle)
---
Delete
Reveal trigger is not in the context menu — every element has a reveal field surfaced in the right-pane Properties inspector (§8). See Shared element properties.
Lock semantics: locked element cannot be dragged, resized, or rotated on the canvas. All other property edits (text, colour, reveal trigger, object-type-specific fields) stay available in the inspector. Locked elements show a lock badge in the selection chrome. Toggle from the context menu or the inspector lock affordance.
7a. Notes pane (below canvas)
PowerPoint-style notes pane stuck below the canvas region, always visible while a slide is selected. Authors per-slide host-notes (F-DE-19) in either plain text or markdown. Markdown is rendered at runtime on the Remote and on the Host operator window (F-HO-25) — never on the audience-facing main display, never on any Client.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.pane.notes |
panel | Notes (markdown / text) | designer.main-shell |
— | MVP | F-DE-19 | spec | — |
designer.pane.notes.textarea |
inline | (notes editor) | designer.pane.notes |
inline | MVP | F-DE-19 | spec | — |
designer.pane.notes.toggle-collapse |
inline | ⌄ / ⌃ collapse | designer.pane.notes |
inline | MVP | F-DE-19 | spec | — |
designer.pane.notes.toggle-mode |
inline | Edit / Preview | designer.pane.notes |
inline (toggles markdown preview) | MVP | F-DE-19 | spec | — |
designer.pane.notes.empty-state |
inline | "No notes — author cues for the quizmaster here" | designer.pane.notes |
inline | MVP | F-DE-19 | spec | — |
8. Right pane — context-driven (Properties / Insert / Slide)
The right pane is tabbed and context-driven: no selection → Insert palette; element selected → Properties; slide selected → slide-level Properties. Lives in main-shell.html (Insert state), main-shell-element-selected.html, main-shell-slide-selected.html, main-shell-multiselect.html.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.pane.right |
panel | (right pane) | designer.main-shell |
— | MVP | — | static | main-shell |
designer.pane.right.tab.properties |
tab | Properties | right pane | inline | MVP | F-DE-5, F-DE-6 | static | main-shell-* |
designer.pane.right.tab.insert |
tab | Insert | right pane | inline | MVP | F-DE-7 | static | main-shell |
designer.pane.right.insert.search |
inline | Search… | insert tab | inline | MVP | F-DE-7 | static | main-shell |
designer.pane.right.insert.cat.text |
inline | TEXT | insert tab | inline (drag onto canvas) | MVP | F-DE-7 | static | main-shell |
designer.pane.right.insert.cat.questions |
inline | QUESTIONS | insert tab | inline | MVP | F-DE-7 | static | main-shell |
designer.pane.right.insert.cat.media |
inline | MEDIA | insert tab | inline | Alpha | F-DE-7, F-DE-8 | static | main-shell |
designer.pane.right.insert.cat.timing-score |
inline | TIMING & SCORE | insert tab | inline | MVP | F-DE-7, F-DE-9, F-DE-10 | static | main-shell |
designer.pane.right.properties.element |
inline | (per object-type editor) | properties tab + element selected | inline | MVP | F-DE-5, F-DE-6, F-DE-7 | static | main-shell-element-selected |
designer.pane.right.properties.element.shared |
inline | (shared element properties section) | properties tab + element selected | inline | MVP | F-DE-5, F-DE-6, F-DE-20 | spec | — |
designer.pane.right.properties.element.reveal |
inline | (reveal trigger + animation) | properties tab + element selected | inline | MVP | F-DE-20 | spec | — |
designer.pane.right.properties.round |
inline | (round-level scoring fields) | properties tab + round-header selected | inline | MVP | F-DE-4, F-DE-9 | spec | — |
designer.pane.right.properties.slide |
inline | (slide-level fields) | properties tab + slide selected | inline | MVP | F-DE-9, F-DE-10, F-DE-19 | static | main-shell-slide-selected |
designer.pane.right.properties.multiselect |
inline | (N selected) | properties tab + N>1 | inline | MVP | F-DE-3, F-DE-4 | static | main-shell-multiselect |
9. Library panel + picker
Asset library — full-shell panel (browse + import) and a modal picker (pick when binding to a property). Lives in library-panel.html and library-picker.html.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.panel.library |
panel | Library | View menu · statusbar | inline (browse / import) | Alpha | F-DE-8 | static | library-panel |
designer.panel.library.import |
toolbar | + Import… | library panel | OS file picker → inline (rows added) | Alpha | F-DE-8 | static | library-panel |
designer.panel.library.filter |
inline | (kind / round / used / unused chips) | library panel | inline | Alpha | F-DE-8 | static | library-panel |
designer.panel.library.row.context |
context-menu | Right-click asset | library panel | designer.menu.library-context.* |
Alpha | F-DE-8 | spec | — |
designer.menu.library-context.rename |
submenu | Rename | library-context | inline | Alpha | F-DE-8 | spec | — |
designer.menu.library-context.replace |
submenu | Replace file… | library-context | OS file picker | Alpha | F-DE-8 | spec | — |
designer.menu.library-context.reveal |
submenu | Reveal in OS file browser | library-context | external | Alpha | F-DE-8 | spec | — |
designer.menu.library-context.delete |
submenu | Delete from library | library-context | inline (confirm if used) | Alpha | F-DE-8 | spec | — |
designer.dialog.library-picker |
dialog | Pick from library | property field (image/audio/video) · empty-state | inline (bind to property) | Alpha | F-DE-8 | static | library-picker |
10. Keyboard shortcuts (global)
Global shortcuts mirror their menu IDs and so are not re-numbered. The matrix references the canonical surface; this section is just the index for Ctrl ? (shortcut help dialog) and quick scanning.
| Shortcut | Surface ID |
|---|---|
| Ctrl N | designer.menu.file.new |
| Ctrl O | designer.menu.file.open |
| Ctrl S | designer.menu.file.save |
| Ctrl Shift S | designer.menu.file.save-as |
| Ctrl Shift P | designer.menu.file.push-to-host |
| F5 | designer.menu.file.run-from-slide |
| Ctrl , | designer.menu.file.preferences |
| Ctrl Q | designer.menu.file.quit |
| Ctrl Z | designer.menu.edit.undo |
| Ctrl Shift Z | designer.menu.edit.redo |
| Ctrl X / C / V / D | cut / copy / paste / duplicate |
| Ctrl ] / Ctrl Shift ] | bring forward / bring to front |
| Ctrl / Ctrl Shift [ | send backward / send to back |
| Del | delete |
| Ctrl A | select-all |
| Ctrl F | find |
| Ctrl + / − / 0 / 1 | zoom |
| Ctrl ? | designer.menu.help.shortcuts |
| Esc | clear selection (canvas focus); quit local Run-from-slide runner if active |
| Enter / F2 | rename selected slide / round |
| Space (hold) | pan canvas |
| N | focus notes pane (slide selected) |
11. Gaps + decisions to make
Items prefixed TODO. above are unspecified surfaces. Resolved items struck through; open items remain.
Resolved 2026-05-11:
- ~~Preferences scope~~ — MVP-minimal: Theme · Autosave · Defaults tabs, built to extend.
- ~~Host-notes placement~~ — PowerPoint-style notes pane below the canvas, always visible. Markdown or plain text. Renders at runtime on Remote + Host operator window (F-DE-19, F-HO-25).
- ~~Push-to-Host CTA~~ — File-menu only; toolbar CTA slot belongs to Run from slide (F-DE-27, F5).
- ~~Remote pairing UX~~ — both numeric code and QR ship (Open Questions #9).
- ~~Run-from-slide confirm~~ — fire-and-spawn instantly, no confirm. Esc inside runner returns to Designer.
- ~~Element / slide / round context-menus~~ — order + grouping recorded in §7 (element), §6 (slide / round) above.
- ~~Lock semantics~~ — position / size / rotation locked on canvas; inspector edits stay available; lock badge in selection chrome.
- ~~Reveal trigger~~ — every element has a
revealfield in the right-pane Properties inspector; no modal dialog. See [Shared element properties. - ~~Round scoring~~ — selecting a round-header swaps the right-pane Properties to round-level fields (same pattern as element / slide selection). No modal.
- ~~Scoreboard~~ —
core.leaderboardis an object type, configured via the inspector like every other element (top-N teams, animation, etc.). See object-types.md.
Open:
- Recent files (all) dialog — list, search, filter.
- Shortcuts dialog — auto-generated from keymap table or hand-written.
- About dialog — version, build hash, credits, license content.
- Library import resource dialog — drop-target + form vs straight-through OS picker.
- Multi-select operations spec — already mocked but matrix entries thin; which bulk ops are supported on N selected slides / elements.
- Animation catalogue —
reveal.animationkeys (Beta-phase brand-true motion language). - Insert palette taxonomy growth — when new object types ship (
core.numeric-input,core.drawing-input,core.audio-clip, etc.) which category they live in.
Designer flows designer
Surface-to-surface navigation flows for the Designer. Each diagram shows the happy path forward only — same-screen edits (drag, type, click property, undo / redo, save) live in prose under the diagram rather than as self-loops on the canvas. Authoring conventions and theme tokens live in .claude/skills/mermaid-diagrams/SKILL.md.
Surfaces referenced here are defined in Designer surfaces. Functional requirements are in Functional Requirements — Designer.
1. App lifecycle
Mockups: Splash · Empty state · New quiz · Recent files · Quit unsaved
On exit with unsaved changes the Quit-unsaved dialog blocks (Save → Save flow → Exit · Don't save → Exit · Cancel → Main shell). On a crash, next launch detects the orphaned draft and routes through the recovery banner before Empty state.
2. File menu
Mockups: File menu · Recent files · Preferences · Restore from backup
The File menu is a fan-out — every item launches an action and returns to the Main shell (or exits). Drawn as a list because there's no per-item flow, just per-item destination:
| Item | Shortcut | Destination |
|---|---|---|
| New quiz… | Ctrl N |
New-quiz dialog → Main shell |
| Open… | Ctrl O |
OS file picker → Main shell |
| Open recent ▸ | — | Recent submenu → Main shell |
| Save | Ctrl S |
Inline save · status pulse |
| Save as… | Ctrl Shift S |
OS save picker → Main shell |
| ▸ Run from slide | F5 |
Spawns local Host process — see flow 5 |
| Push to Host… | — | See flow 4 |
| Restore from backup… | — | Restore dialog → Main shell |
| Import quiz package… | — | OS picker → Main shell |
Export .quiz… |
— | OS save picker |
| Preferences… | Ctrl , |
Preferences dialog (modal — Theme · Autosave · Defaults) |
| Sign in… Stretch | — | Sign-in dialog |
| Quit | Ctrl Q |
Quit-unsaved if dirty, else Exit |
3. Authoring loop
Mockups: Main shell · Slide selected · Element selected · Multi-select · Slide context menu · Element context menu · Round context menu
slide list + canvas + right pane] -->|click slide| SS[Slide selected] SS -->|click element| ES[Element selected] ES -->|right pane| RPI[Properties inspector
shared block + reveal + lock + type editor]
Three selection states inside the Main shell — slide-selected, element-selected, multi-select — drive what the right pane shows (slide properties / element properties / multi-select summary / Insert palette). Selection is the loop, not the navigation.
Slide-list context menu (right-click a row): Duplicate · Move to round ▸ · New round from selection · Focus notes pane · Delete. Each mutates the slide list in place.
Round-header context menu: Rename (inline) · Round scoring (opens right pane) · Ungroup · Delete round + slides (confirm).
Element-canvas context menu (right-click an element): Cut / Copy / Paste / Duplicate · Bring to front / Send to back / forward / backward · Copy to other canvas · Lock / Unlock · Delete.
All context actions stay in the Main shell — they edit the model, the canvas + right pane re-render, and undo (Ctrl Z) reverses every step. No navigation.
4. Push to Host
Mockups: Push to Host · Transferring
Bonjour host list] -->|pick host| D2[Transferring
chunked CRC32 + resume] D2 -->|host accepts| D3[Done banner] D3 --> MS[Main shell] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class MS terminal
Failure branches (each surfaces inside the dialog, no separate screen): host rejects → returns to host picker; connection drops → in-place resume-from-offset, progress bar reflects retries; CRC fail → automatic re-send of failed chunks.
5. Run from slide (local)
Mockups: Main shell — preview mode
Designer process stays alive while the Host child runs. On a multi-display setup the Host opens its operator + audience windows automatically. Esc inside the Host quits the child and returns focus to the Designer.
6. Library
Mockups: Library panel · Library picker · Import resource
Asset row context menu (right-click): Rename · Replace file… (→ OS picker) · Reveal in OS browser · Delete (confirm if in use). Each mutates the panel in place. The picker (modal) is the only navigational sub-screen — it returns to Main shell with the picked asset wired to the active property field.
See also: Designer surfaces · Architecture — Designer shell · Functional Requirements — Designer.
Host surfaces host
Every entry-point in the Host app — idle screens, library, receive-from-Designer flow, join screen, in-session controls, Remote pairing, recovery. No mockups exist yet — every row below is spec until backfilled. See UI Surfaces — matrix columns for column meanings.
Linked from Applications — Host, Functional Requirements — Host, Design Spec, Design Spec — Studio (operator window chrome).
Host targets: iPad, Windows, macOS, Android tablet. Touch and mouse/keyboard both first-class. Typically connected to a projector / TV via HDMI or wireless display.
The Host opens in dual-window operator-view mode when more than one display is connected (F-HO-25): the audience window owns the projector / TV (clean slide canvas) and the operator window sits on the operator's laptop / iPad screen with mirror + host-notes + controls. Both windows share the same process; surfaces below are tagged by which window they live in.
1. Top-level screens
Host has three top-level modes — idle (no quiz loaded), loaded (quiz loaded, not started), session (live play). Plus recovery flow on launch with a saved session.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.screen.splash |
dialog | (logo + version) | root | host.screen.idle / host.dialog.resume-session |
MVP | — | spec | — |
host.screen.idle |
panel | Idle / no quiz loaded | root | various | MVP | F-HO-3, F-HO-5, F-HO-4 | spec | — |
host.screen.loaded |
panel | Quiz loaded, awaiting start | root | host.screen.join |
MVP | F-HO-8 | spec | — |
host.screen.join |
panel | Join screen (teams connect) | root | host.screen.session |
MVP | F-HO-9 | spec | — |
host.screen.session |
panel | Live session — slide rendering + controls | root | host.screen.session.end |
MVP | F-HO-11, F-HO-14 | spec | — |
host.screen.session.end |
panel | Final standings | root | host.screen.loaded |
MVP | F-HO-14 | spec | — |
host.dialog.resume-session |
dialog | Resume previous session? | startup | host.screen.session (resume) / host.screen.loaded (start fresh) |
Alpha | F-HO-18, F-HO-19 | spec | — |
2. Idle screen surfaces
Shown when no quiz is loaded. Lists loaded packages, lets operator pick one or load more.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.idle.quiz-list |
panel | Loaded quizzes | host.screen.idle |
inline (select) → host.screen.loaded |
MVP | F-HO-5, F-HO-6 | spec | — |
host.idle.quiz-list.row.context |
context-menu | Long-press / right-click row | host.idle.quiz-list |
host.menu.quiz-context.* |
MVP | F-HO-5 | spec | — |
host.menu.quiz-context.start |
submenu | Start session | quiz-context | host.screen.join |
MVP | F-HO-8 | spec | — |
host.menu.quiz-context.preview |
submenu | Preview slides | quiz-context | host.screen.loaded |
MVP | — | spec | — |
host.menu.quiz-context.delete |
submenu | Delete from device | quiz-context | inline (confirm) | MVP | F-HO-6 | spec | — |
host.idle.toolbar.load-file |
toolbar | Load .quiz from file… |
host.screen.idle |
OS file picker | MVP | F-HO-5 | spec | — |
host.idle.toolbar.signin |
toolbar | Sign in / cloud library | host.screen.idle |
TODO.host.dialog.signin / TODO.host.dialog.cloud-library |
Stretch | F-HO-1, F-HO-2 | spec | — |
host.idle.toolbar.settings |
toolbar | Settings | host.screen.idle |
TODO.host.dialog.settings |
MVP | — | spec | — |
host.idle.network-banner |
inline | "Visible on network as |
host.screen.idle |
inline | MVP | F-HO-3 | spec | — |
host.idle.session-code |
inline | "Or join with code · Q7-3K9-FX" | host.screen.idle |
inline | Stretch | F-X-6 | spec | — |
3. Incoming-transfer flow (Designer push)
Triggered by a Designer pushing a .quiz to this Host. Modal prompt with accept/reject. Mirror of Designer push-to-host.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.dialog.incoming-transfer |
dialog | Incoming quiz from |
host.screen.idle (push) |
accept → host.dialog.incoming-transfer.receiving / reject → host.screen.idle |
MVP | F-HO-4 | spec | — |
host.dialog.incoming-transfer.receiving |
dialog | Receiving X MB… | incoming-transfer | host.dialog.incoming-transfer.done / host.dialog.incoming-transfer.error |
MVP | F-HO-4 | spec | — |
host.dialog.incoming-transfer.done |
dialog | Quiz received | receiving | host.screen.idle (refreshed) |
MVP | F-HO-4 | spec | — |
host.dialog.incoming-transfer.error |
dialog | Transfer failed | receiving | host.screen.idle |
MVP | F-HO-4 | spec | — |
host.dialog.package-incompatible |
dialog | Quiz needs newer Host (missing object types) | various load entry-points | host.screen.idle |
MVP | F-HO-7 | spec | — |
4. Join screen surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.join.team-roster |
panel | Connected teams | host.screen.join |
inline | MVP | F-HO-9 | spec | — |
host.join.qr-code |
panel | "Join at |
host.screen.join |
inline | MVP | F-HO-3, F-HO-9 | spec | — |
host.join.session-code |
panel | "Or type code · Q7-3K9-FX" | host.screen.join |
inline | Stretch | F-X-6 | spec | — |
host.join.team-roster.photo |
inline | (team photo on roster row) | host.join.team-roster |
inline | Alpha | F-HO-26, F-CL-14 | spec | — |
host.join.team-row.context |
context-menu | Long-press / right-click team row | join roster | host.menu.team-context.* |
MVP | F-HO-9 | spec | — |
host.menu.team-context.rename |
submenu | Rename team | team-context | inline | MVP | F-HO-9 | spec | — |
host.menu.team-context.kick |
submenu | Kick team | team-context | inline (confirm) | MVP | F-HO-15 | spec | — |
host.menu.team-context.assign-colour |
submenu | Assign team colour | team-context | inline | Beta | F-CL-12, F-DE-21 | spec | — |
host.join.start-button |
toolbar | Start quiz | host.screen.join |
host.screen.session |
MVP | F-HO-8 | spec | — |
host.join.back-button |
toolbar | Back to library | host.screen.join |
host.screen.idle (confirm if teams joined) |
MVP | F-HO-8 | spec | — |
5. Session surfaces
In-session controls — slide rendering, navigation, timer override, scoring overrides.
Audience window (the projector / TV surface) shows only the slide canvas + audience-safe overlays. Operator window (F-HO-25, second display or single-display overlay) holds every control surface, mirror, notes, and HUD — same controls a paired Remote can drive.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.session.audience.canvas |
inline | (current slide Host canvas) | audience window | inline | MVP | F-HO-11 | spec | — |
host.session.operator.window |
panel | Operator window | second display / overlay | — | MVP | F-HO-25 | spec | — |
host.session.operator.mirror |
inline | (audience-screen mirror) | host.session.operator.controls |
inline | MVP | F-HO-25 | spec | — |
host.session.operator.notes |
inline | (current slide notes, markdown-rendered) | host.session.operator.controls |
inline (scroll) | MVP | F-DE-19, F-HO-25 | spec | — |
host.session.operator.controls |
inline | (nav + timer + reveal + scoring) | host.session.operator.controls |
— | MVP | F-HO-17, F-HO-25 | spec | — |
host.session.fallback-overlay |
inline | Single-display overlay fallback | host.session.audience.canvas |
inline (tap / mouse to summon) | MVP | F-HO-11, F-HO-17, F-HO-25 | spec | — |
host.session.toolbar.next |
toolbar | Next slide · → | host.session.operator.controls |
inline | MVP | F-HO-11 | spec | — |
host.session.toolbar.prev |
toolbar | Previous slide · ← | host.session.operator.controls |
inline | MVP | F-HO-11 | spec | — |
host.session.toolbar.timer-extend |
toolbar | + 10 s | host.session.operator.controls |
inline | MVP | F-HO-17 | spec | — |
host.session.toolbar.timer-skip |
toolbar | Skip timer | host.session.operator.controls |
inline | MVP | F-HO-17 | spec | — |
host.session.toolbar.timer-lock |
toolbar | Lock now | host.session.operator.controls |
inline | MVP | F-HO-17 | spec | — |
host.session.toolbar.timer-unlock |
toolbar | Unlock | host.session.operator.controls |
inline | MVP | F-HO-17 | spec | — |
host.session.toolbar.reveal |
toolbar | Trigger reveal | host.session.operator.controls |
inline | Alpha | F-DE-20, F-HO-24 | spec | — |
host.session.toolbar.show-leaderboard |
toolbar | Show leaderboard | host.session.operator.controls |
inline | MVP | F-HO-14 | spec | — |
host.session.toolbar.jump-to-slide |
toolbar | Jump to slide… | host.session.operator.controls |
TODO.host.dialog.jump-to-slide |
Alpha | F-HO-24 | spec | — |
host.session.toolbar.score-override |
toolbar | Score overrides | host.session.operator.controls |
TODO.host.dialog.score-overrides |
Alpha | F-HO-24 | spec | — |
host.session.toolbar.pause |
toolbar | Pause session | host.session.operator.controls |
TODO.host.dialog.pause |
MVP | F-HO-11 | spec | — |
host.session.toolbar.end-session |
toolbar | End session… | host.session.operator.controls |
TODO.host.dialog.end-session (confirm) |
MVP | F-HO-14 | spec | — |
host.session.hud.timer |
inline | (countdown) | host.session.canvas |
inline | MVP | F-HO-16 | spec | — |
host.session.hud.slide-counter |
inline | "Slide N / M" | host.session.operator.controls |
inline | MVP | F-HO-11 | spec | — |
host.session.hud.team-status |
inline | (teams who've answered) | host.session.operator.controls |
inline | MVP | F-HO-14, F-HO-15 | spec | — |
host.session.hud.team-photo |
inline | (team photo on leaderboard + callouts) | host.session.audience.canvas · operator scoreboard |
inline | Alpha | F-HO-26, F-CL-14 | spec | — |
host.session.audio.jingle |
inline | (per-team buzzer jingle playback) | host.session.audience.canvas (audio out) |
inline (fires on buzzer-in first-press win) | Alpha | F-HO-27, F-CL-15 | spec | — |
host.session.operator.jingle-mute |
toolbar | Mute jingles | host.session.operator.controls |
inline (toggle, session-scoped) | Alpha | F-HO-27 | spec | — |
host.dialog.client-disconnect |
notification | " |
session | inline | MVP | F-HO-15 | spec | — |
host.dialog.jump-to-slide |
dialog | Jump to slide | host.session.operator.controls |
inline | Alpha | F-HO-24 | static | host/jump-to-slide.html |
host.dialog.score-overrides |
dialog | Score overrides | host.session.operator.controls |
inline | Alpha | F-HO-24 | static | host/score-overrides.html |
host.dialog.pause |
dialog | Session paused | host.session.operator.controls |
resume / end | MVP | F-HO-11 | static | host/pause.html |
host.dialog.end-session |
dialog | End this session? | host.session.operator.controls |
host.screen.session.end / cancel |
MVP | F-HO-14 | static | host/end-session-confirm.html |
6. Remote pairing surfaces
Pairing offers both options simultaneously per F-HO-20 — short numeric pairing code + QR encoding the same code. Remote can type or scan.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.dialog.remote-pair-prompt |
dialog | Pair a Remote — code + QR | join screen · settings · operator menu | inline (waits for Remote) | MVP | F-HO-20 | spec | — |
host.dialog.remote-pair-prompt.code |
inline | (short numeric pairing code) | pair-prompt | inline | MVP | F-HO-20 | spec | — |
host.dialog.remote-pair-prompt.qr |
inline | (QR encoding pairing code) | pair-prompt | inline | MVP | F-HO-20 | spec | — |
host.dialog.remote-pair-prompt.regenerate |
toolbar | Regenerate code | pair-prompt | inline | MVP | F-HO-20 | spec | — |
host.dialog.remote-pair-accept |
dialog | Accept Remote |
Remote attempt | accept → paired / reject → dismissed | MVP | F-HO-20 | spec | — |
host.session.hud.remote-indicator |
inline | (Remote-paired icon) | host.session.operator.controls |
inline | MVP | F-HO-20, F-HO-21 | spec | — |
host.dialog.remote-disconnected |
notification | Remote disconnected | session | inline | MVP | F-HO-20 | spec | — |
7. Settings + ancillary
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.dialog.settings |
dialog | Settings | idle toolbar | inline tabs (display, audio, network, advanced) | MVP | — | static | host/settings.html |
host.dialog.settings.display |
tab | Display | settings | inline (HDMI / AirPlay / resolution / overscan) | MVP | F-HO-11 | static | host/settings.html |
TODO.host.dialog.settings.display.pick-audience |
inline | Audience display ▾ | display tab | inline (select connected screen) | MVP | F-HO-25 | spec | — |
TODO.host.dialog.settings.display.operator-window |
inline | Operator window ▾ | display tab | inline (select other screen / disable / overlay-on-single-screen) | MVP | F-HO-25 | spec | — |
TODO.host.dialog.settings.audio |
tab | Audio | settings | inline | Beta | F-X-5 | spec | — |
TODO.host.dialog.settings.network |
tab | Network | settings | inline (advertised name, Wi-Fi info) | MVP | F-HO-3 | spec | — |
TODO.host.dialog.settings.advanced |
tab | Advanced | settings | inline (logs, telemetry, factory reset) | MVP | — | spec | — |
TODO.host.dialog.signin |
dialog | Sign in to cloud | idle toolbar | inline | Stretch | F-HO-1 | spec | — |
TODO.host.dialog.cloud-library |
dialog | Cloud library | idle toolbar | inline (browse + download) | Stretch | F-HO-2 | spec | — |
TODO.host.dialog.broadcast-mode |
dialog | Broadcast mode | settings · session overlay | inline | Stretch | F-HO-22 | spec | — |
8. Gaps + decisions to make
Everything is currently spec. Mockup priorities before any other:
- Idle screen — first user impression after launch; what does it look like with 0 / 1 / N loaded quizzes? Network banner copy and placement. Session-code surface (Stretch) — placement when both LAN-only and internet-joinable.
- Join screen — QR placement, team-roster density, copy when 0 teams joined.
- Operator window layout (F-HO-25) — mirror size vs notes prominence vs control density; one-handed vs sit-at-laptop ergonomics.
- Single-display fallback overlay — gesture/tap to summon, auto-hide timing, which controls are visible by default vs in a sub-menu.
- Incoming-transfer modal — accept/reject affordance, mid-transfer progress, Designer identification copy.
- Resume-session dialog — Alpha-phase; copy when the saved-session quiz no longer matches.
- Display-config UX — first-launch with multi-display: auto-detect or always ask. Drag-to-arrange screens visual? OS-native picker?
- Settings dialog — minimum surface for MVP vs Beta.
Host flows host
Surface-to-surface navigation flows for the Host. Each diagram shows the happy path forward only — same-screen actions (next slide, +10s, lock, mute jingles, etc.) live in prose under the diagram, not as self-loops on the canvas. Authoring conventions and theme tokens live in .claude/skills/mermaid-diagrams/SKILL.md.
Surfaces in Host surfaces. Functional requirements in Functional Requirements — Host.
1. App lifecycle
Mockups: Idle · Resume session · Session — operator · Final standings
Off-path returns (each is a banner / standard back action, not a primary edge): Final standings → Back returns to Idle for the next quiz; session crash → relaunch surfaces Resume dialog.
2. Idle and load
Mockups: Idle · Incoming transfer · Package incompatible · Settings
loaded quizzes + toolbar] -->|tap quiz| LD[Loaded · awaiting start] ID -->|Load file… / incoming push| RCV[Receive + validate] RCV -->|valid| ID RCV -->|type mismatch| PI[Package-incompatible] LD --> JO[Join screen] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class JO terminal
On-screen actions (no navigation): toolbar — Settings dialog, Sign-in (Stretch), session-code visibility. Quiz row long-press opens context menu (Start session → Join · Preview slides → Loaded · Delete from device). Incoming-transfer prompt confirms → progress bar → done banner; reject returns to Idle.
3. Join and start
Mockups: Join screen · Remote pair prompt
QR + URL + code + roster] -->|Start quiz ▸| SE[Session] JO --> RP[Remote pair prompt] RP -->|Remote accepted| JO classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class SE terminal
Roster actions (no navigation): team rows fill as Clients connect; long-press a row → context menu (Rename / Kick / Assign colour — Beta). Back to library: prompts confirm if any teams have joined; returns to Idle.
4. Session controls (operator window)
Mockups: Operator window · Audience window · Jump to slide · Score overrides · Pause · End-session confirm · Final standings
operator + audience windows] SE --> PA[Paused overlay] SE --> JTS[Jump dialog — Alpha] SE --> SO[Score overrides — Alpha] SE --> ENC[End-session confirm] ENC -->|End| EN[Final standings] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class EN terminal
Inline session controls (no navigation — buttons fire commands, audience window updates in place): Next slide / Previous · + 10 s / Skip timer / Lock now / Unlock · Show leaderboard · Trigger reveal (Alpha) · Mute jingles. Off-path overlays: Paused overlay → Resume; Jump / Overrides modals → Apply or Cancel returns to Session. Disconnects surface as banners (Client disconnect · Remote disconnect) and disappear on reconnect; no navigation.
5. Single-display fallback
Mockups: Audience window (overlay state)
On a machine with one display the operator window becomes a summoned overlay, not a separate window. Inputs (tap / move mouse) reveal the control row; auto-hides after 4 s of inactivity. Both states are the same Session screen — no separate node.
6. Remote pairing
Mockups: Remote pair prompt
code + QR] --> WAIT[Wait for Remote] WAIT --> RA[Accept-Remote prompt] RA -->|Accept| PAIRED[Paired] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class PAIRED terminal
On-screen actions: Regenerate code (cycles the visible code, stays on prompt); Reject (back to waiting). Remote disconnect while paired returns the operator HUD to "no Remote paired" state inline; no navigation.
See also: Host surfaces · Networking · Functional Requirements — Host.
Client surfaces client
Every entry-point in the Client app — discover, join, in-session (responsive Client canvas + input), standings, reconnect. No mockups exist yet — every row below is spec until backfilled. See UI Surfaces — matrix columns for column meanings.
Linked from Applications — Client, Functional Requirements — Client, Design Spec.
Client targets: iPhone, Android phone, iPad, Android tablet. One shared device per team. Responsive layout — phone portrait/landscape, tablet portrait/landscape. Touch-first.
1. Top-level screens
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.screen.splash |
dialog | (logo + version) | root | client.screen.discover |
MVP | — | spec | — |
client.screen.discover |
panel | Find a host | root | client.screen.join |
MVP | F-CL-1 | spec | — |
client.screen.code-entry |
panel | Type session code | client.screen.discover |
client.screen.join (via internet relay) |
Stretch | F-X-6 | spec | — |
client.screen.join |
panel | Join — enter team name | root | client.screen.session |
MVP | F-CL-2 | spec | — |
client.screen.eager-push-progress |
panel | Loading quiz… (progress) | root | client.screen.session |
MVP | F-CL-3 | spec | — |
client.screen.session |
panel | Live slide rendering + input | root | client.screen.session.end |
MVP | F-CL-5 | spec | — |
client.screen.session.end |
panel | Final standings + thanks | root | client.screen.discover |
MVP | F-CL-8 | spec | — |
client.screen.reconnect |
panel | Reconnecting… | root | client.screen.session (on reconnect) |
MVP | F-CL-9 | spec | — |
client.screen.fatal-mismatch |
panel | This host needs a newer Client | root | client.screen.discover |
MVP | F-CL-4 | spec | — |
2. Discover surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.discover.host-list |
panel | Hosts on this Wi-Fi | client.screen.discover |
inline (select) → client.screen.join |
MVP | F-CL-1 | spec | — |
client.discover.host-row |
inline | (Host name · quiz title) | host-list | inline (select) | MVP | F-CL-1 | spec | — |
client.discover.refresh |
toolbar | Refresh | client.screen.discover |
inline | MVP | F-CL-1 | spec | — |
client.discover.code-entry-toggle |
toolbar | Have a code? Tap to enter | client.screen.discover |
client.screen.code-entry |
Stretch | F-X-6 | spec | — |
client.code-entry.input |
inline | Session code input (Q7-3K9-FX) | client.screen.code-entry |
inline (resolve → connect) | Stretch | F-X-6 | spec | — |
client.code-entry.submit |
toolbar | Join with code | client.screen.code-entry |
client.screen.join |
Stretch | F-X-6 | spec | — |
client.code-entry.back |
toolbar | Back to scan | client.screen.code-entry |
client.screen.discover |
Stretch | — | spec | — |
client.dialog.code-not-found |
dialog | Code didn't match | code-entry submit | client.screen.code-entry (retry) |
Stretch | F-X-6 | spec | — |
client.discover.empty-state |
inline | No hosts found (troubleshooting hints) | client.screen.discover |
inline | MVP | F-CL-1 | spec | — |
client.discover.menu |
menu | ⋯ | client.screen.discover |
client.menu.app.* |
MVP | — | spec | — |
client.menu.app.settings |
submenu | Settings | app menu | TODO.client.dialog.settings |
MVP | — | spec | — |
client.menu.app.about |
submenu | About | app menu | TODO.client.dialog.about |
MVP | — | spec | — |
3. Join surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.join.team-name |
inline | Team name input | client.screen.join |
inline | MVP | F-CL-2 | spec | — |
client.join.team-colour |
inline | Pick team colour | client.screen.join |
inline | Beta | F-CL-12 | spec | — |
client.join.team-avatar |
inline | Pick team avatar (palette tile) | client.screen.join |
inline | Beta | F-CL-12 | spec | — |
client.join.photo |
inline | Take photo / Choose photo | client.screen.join |
client.dialog.photo-capture / client.dialog.photo-crop |
Alpha | F-CL-14 | spec | — |
client.join.photo.avatar-fallback |
inline | Or pick from {N} avatars | client.screen.join |
inline (modal of premade avatars) | Alpha | F-CL-14, F-DE-30 | spec | — |
client.dialog.photo-capture |
dialog | Camera permission + capture | photo button | client.dialog.photo-crop |
Alpha | F-CL-14 | spec | — |
client.dialog.photo-crop |
dialog | Crop to square | photo-capture | inline (saves to join state) | Alpha | F-CL-14 | spec | — |
client.join.buzzer-jingle |
inline | Pick a buzzer sound | client.screen.join |
client.dialog.buzzer-picker |
Alpha | F-CL-15 | spec | — |
client.dialog.buzzer-picker |
dialog | Buzzer jingle picker (preview + select) | buzzer button | inline (saves to join state) | Alpha | F-CL-15 | spec | — |
client.join.buzzer-jingle.empty |
inline | "Quizmaster didn't add buzzers" | client.screen.join |
inline (hidden when none bundled) | Alpha | F-CL-15 | spec | — |
client.join.submit |
toolbar | Join | client.screen.join |
client.screen.eager-push-progress |
MVP | F-CL-2 | spec | — |
client.join.back |
toolbar | Back | client.screen.join |
client.screen.discover |
MVP | — | spec | — |
client.dialog.rejoin-previous |
dialog | Rejoin as |
join screen on reconnect | inline | Alpha | F-CL-10 | spec | — |
client.dialog.team-name-taken |
dialog | Team name taken | join submit | inline | MVP | F-CL-2 | spec | — |
client.dialog.host-rejected-join |
dialog | Host rejected join | join submit | client.screen.discover |
MVP | F-CL-15 | spec | — |
4. Session surfaces
The session screen is dominated by the slide's Client canvas (driven entirely by the slide's element behaviours). Persistent chrome wraps it.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.session.canvas |
inline | (current slide Client canvas) | client.screen.session |
inline | MVP | F-CL-5 | spec | — |
client.session.hud.team-name |
inline | (team name · colour · avatar) | client.screen.session |
client.session.menu (tap) |
MVP | F-CL-2, F-CL-12 | spec | — |
client.session.hud.score |
inline | (team score) | client.screen.session |
client.session.standings (tap) |
MVP | F-CL-8 | spec | — |
client.session.hud.timer |
inline | (countdown, mirrors Host) | client.session.canvas |
inline | MVP | F-CL-11 | spec | — |
client.session.hud.locked-banner |
inline | "Time's up — answers locked" | client.session.canvas |
inline | MVP | F-CL-11 | spec | — |
client.session.standings |
panel | Live standings | session HUD score | inline (swipe to dismiss) | MVP | F-CL-8 | spec | — |
client.session.menu |
menu | ⋯ | session HUD | client.menu.session.* |
MVP | — | spec | — |
client.menu.session.change-name |
submenu | Change team name | session menu | TODO.client.dialog.change-team-name |
MVP | F-CL-2 | spec | — |
client.menu.session.leave |
submenu | Leave session | session menu | confirm → client.screen.discover |
MVP | — | spec | — |
client.menu.session.report-issue |
submenu | Report an issue | session menu | external / inline | MVP | — | spec | — |
4.1 Element-driven input surfaces
Each placed element on the Client canvas owns its own input UI. These are not part of the shell — they ship with each object type. Surfaces listed here are the categories, not specific element editors.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.element.multiple-choice |
inline | (A/B/C/D tap targets) | client.session.canvas |
inline | MVP | F-CL-6 | spec | — |
client.element.free-text |
inline | (text input) | client.session.canvas |
inline (submit) | MVP | F-CL-6 | spec | — |
client.element.drawing |
inline | (canvas + brush) | client.session.canvas |
inline (submit) | Alpha | F-CL-6, F-DE-17 | spec | — |
client.element.gesture-buzzer |
inline | (big tap target / shake) | client.session.canvas |
inline | Alpha | F-CL-6 | spec | — |
client.element.mini-game |
inline | (mini-game viewport) | client.session.canvas |
inline | Beta | F-CL-7 | spec | — |
client.element.submitted-feedback |
inline | "Answer received" | element input | inline | MVP | F-CL-6 | spec | — |
5. Reconnect + error surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.notification.reconnecting |
notification | Reconnecting… | session | inline / client.screen.reconnect |
MVP | F-CL-9 | spec | — |
client.notification.host-gone |
notification | Host disconnected | session | client.screen.discover (after timeout) |
MVP | F-CL-9 | spec | — |
client.dialog.session-ended |
dialog | Session ended | session | client.screen.session.end |
MVP | F-CL-8 | spec | — |
6. Settings + ancillary
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.dialog.settings |
dialog | Settings | app menu | inline (sound, vibration, accessibility) | MVP | — | static | client/settings.html |
client.dialog.change-team-name |
dialog | Change team name | session menu | inline | MVP | F-CL-2 | static | client/change-team-name.html |
client.dialog.about |
dialog | About | app menu | inline (version, build) | MVP | — | static | client/about.html |
7. Gaps + decisions to make
Mockup priorities:
- Discover screen — host list density, refresh affordance, empty-state copy. First impression after install.
- Join screen — team-name input, "Joined as
" confirmation, persistence prompt copy in Alpha. Photo capture flow + premade-avatar fallback ordering. Buzzer-picker preview affordance + truncation when many jingles are bundled. - Session HUD — what's always visible vs hidden behind a tap, how the timer renders across phone/tablet form factors.
- Element input library — placeholder editor patterns for MC / free-text / drawing / buzzer. Each object type ships its own visuals; this matrix tracks them as a category list only.
- Locked-banner copy + animation — F-CL-11.
- Standings panel — live during session vs only between rounds vs only on session-end.
- Session-code entry (Stretch) — code format display (chunked
XX-XXX-XXvs flat), what happens on a copy-paste from a chat app, how the screen relates to the LAN discover screen (toggle vs separate screen).
Client flows client
Surface-to-surface navigation for the Client. Each diagram shows the happy path forward only — same-screen actions (refresh, edit answer, swipe between panels, back-to-menu) are listed in prose under the diagram rather than drawn as self-loops. Authoring conventions and theme tokens live in .claude/skills/mermaid-diagrams/SKILL.md.
Surfaces referenced here are defined in Client surfaces. Functional requirements are in Functional Requirements — Client.
1. App lifecycle
Mockups: Splash · Discover · Join · Session · Session end
The Client follows one forward path per session. Off-path returns: session-end → Discover (rejoin next quiz); host-disconnect → Reconnecting banner → Session (auto-retry, no user action); leave-session menu → Discover. Code-entry (Stretch) sits beside Discover for internet relay joins.
2. Discover
Mockups: Discover · Code entry
Bonjour host list] -->|tap live host| JO[Join] DIS -->|Have a code? — Stretch| CE[Code-entry] -->|valid| JO classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class JO terminal
On-screen actions (no navigation): pull-to-refresh, app-menu (Settings / About), tap idle host card → "no quiz loaded" inline notice. Code-entry resolve failure shows an inline "Code didn't match" hint and stays on the code-entry screen.
3. Join and team customisation
Mockups: Team join · Photo capture · Buzzer picker
photo · avatar · buzzer · colour] CUST --> SUBMIT[Join ▸] SUBMIT --> EP[Eager-push] EP --> SE[Session] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class SE terminal
Customisation steps (each returns to the Join screen with the choice applied — modal sub-flows, not navigation): camera capture → crop-to-square; premade-avatar picker; buzzer-jingle picker (preview + select); team-colour picker (Beta).
Failure branches (each surfaces as an inline banner on the Join screen, no navigation): team name taken; host rejected join → Discover; fatal object-type mismatch → Discover; rejoin-as-previous-team dialog on reconnect.
4. Session
Mockups: Session · Session ended
slide canvas + HUD] -->|host locks input| LOCK[Locked banner] LOCK -->|host advances| SE SE -->|host ends quiz| END[Final standings] END -->|Stay for next / Leave| DIS[Discover] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class END,DIS terminal
On-screen actions (no navigation): tap answer, edit until lock, tap score chip → standings panel (swipe to dismiss), tap team chip → session menu (Change team name / Leave / Report issue). Connection drop: Reconnecting banner overlays the session; auto-retry restores Session on reconnect, falls back to "Host gone" → Discover after timeout.
See also: Client surfaces · Functional Requirements — Client.
Remote surfaces remote
Every entry-point in the Remote app — discover, pair, mirror, host-notes, live state, MVP nav commands and the Alpha rich-control set. No mockups exist yet — every row below is spec until backfilled. See UI Surfaces — matrix columns for column meanings.
Linked from Applications — Remote, Functional Requirements — Remote, Design Spec, Design Spec — Studio (chrome theme).
Remote targets: iPhone, Android phone, iPad, Android tablet. Quizmaster carries this while walking the room. One paired Remote per Host at a time, no multi-Remote in v1.
1. Top-level screens
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.screen.splash |
dialog | (logo + version) | root | remote.screen.discover / remote.screen.paired (saved pairing) |
MVP | — | spec | — |
remote.screen.discover |
panel | Find a host | root | remote.screen.pair-pending |
MVP | F-RE-1 | spec | — |
remote.screen.pair-pending |
panel | Waiting for Host confirm | root | remote.screen.paired (accept) / remote.screen.discover (reject) |
MVP | F-RE-2 | spec | — |
remote.screen.paired |
panel | Live control | root | remote.screen.discover (unpair / disconnect) |
MVP | F-RE-3, F-RE-4–F-RE-7 | spec | — |
remote.screen.reconnect |
panel | Reconnecting… | root | remote.screen.paired |
MVP | F-RE-8 | spec | — |
2. Discover + pair surfaces
Pairing offers both options simultaneously per F-RE-2 — scan the QR shown on Host, or type the short numeric pairing code shown next to it.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.discover.host-list |
panel | Hosts on this Wi-Fi | remote.screen.discover |
inline (select) → remote.pair.choose-method |
MVP | F-RE-1 | spec | — |
remote.discover.refresh |
toolbar | Refresh | remote.screen.discover |
inline | MVP | F-RE-1 | spec | — |
remote.discover.empty-state |
inline | No hosts found (troubleshooting hints) | remote.screen.discover |
inline | MVP | F-RE-1 | spec | — |
remote.discover.menu |
menu | ⋯ | remote.screen.discover |
remote.menu.app.* |
MVP | — | spec | — |
remote.menu.app.settings |
submenu | Settings | app menu | TODO.remote.dialog.settings |
MVP | — | spec | — |
remote.menu.app.about |
submenu | About | app menu | TODO.remote.dialog.about |
MVP | — | spec | — |
remote.pair.choose-method |
panel | Pair with |
discover (host selected) | remote.pair.qr-scan / remote.pair.code-entry |
MVP | F-RE-2 | spec | — |
remote.pair.qr-scan |
panel | Scan QR | choose-method | remote.screen.pair-pending |
MVP | F-RE-2 | spec | — |
remote.pair.code-entry |
panel | Enter pairing code | choose-method | remote.screen.pair-pending |
MVP | F-RE-2 | spec | — |
remote.dialog.pair-rejected |
dialog | Host rejected pairing | remote.screen.pair-pending |
remote.screen.discover |
MVP | F-RE-2 | spec | — |
remote.dialog.pair-timeout |
dialog | Host did not respond | remote.screen.pair-pending |
remote.screen.discover |
MVP | F-RE-2 | spec | — |
remote.dialog.pair-code-invalid |
dialog | Code didn't match | code-entry | remote.pair.code-entry (retry) |
MVP | F-RE-2 | spec | — |
3. Paired screen surfaces
Live control screen — laid out for one-handed use while walking. Reference layout: mirror at top, host-notes mid, nav controls bottom (final layout is a mockup decision).
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.paired.mirror |
inline | (Host display preview) | remote.screen.paired |
remote.paired.mirror.fullscreen (tap) |
MVP | F-RE-4 | spec | — |
remote.paired.mirror.fullscreen |
panel | Fullscreen mirror | mirror tap | inline (tap to dismiss) | MVP | F-RE-4 | spec | — |
remote.paired.host-notes |
inline | (per-slide host notes) | remote.screen.paired |
inline (scroll) | MVP | F-RE-5, F-DE-19 | spec | — |
remote.paired.hud.timer |
inline | (countdown) | remote.screen.paired |
inline | MVP | F-RE-6 | spec | — |
remote.paired.hud.slide-counter |
inline | "Slide N / M" | remote.screen.paired |
remote.paired.jump-to-slide (tap, Alpha) |
MVP | F-RE-6 | spec | — |
remote.paired.hud.scores |
inline | (per-team scores) | remote.screen.paired |
remote.paired.scores-detail (tap) |
MVP | F-RE-6 | spec | — |
remote.paired.scores-detail |
panel | Team scores | hud.scores | inline (back) | MVP | F-RE-6 | static | remote/scores-detail.html |
3.1 Core navigation (MVP)
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.paired.toolbar.advance |
toolbar | ▸ Advance | remote.screen.paired |
inline | MVP | F-RE-7 | spec | — |
remote.paired.toolbar.back |
toolbar | ◂ Back | remote.screen.paired |
inline | MVP | F-RE-7 | spec | — |
remote.paired.gesture.swipe-left |
gesture | Swipe left | mirror | inline (= Advance) | MVP | F-RE-7 | spec | — |
remote.paired.gesture.swipe-right |
gesture | Swipe right | mirror | inline (= Back) | MVP | F-RE-7 | spec | — |
3.2 Rich control set (Alpha — F-RE-9)
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.paired.toolbar.jump-to-slide |
toolbar | Jump to slide… | remote.screen.paired |
remote.paired.jump-to-slide |
Alpha | F-RE-9 | spec | — |
remote.paired.jump-to-slide |
panel | Slide picker | jump-to-slide button | inline (select) | Alpha | F-RE-9 | static | remote/jump-to-slide.html |
remote.paired.toolbar.reveal |
toolbar | Trigger reveal ▾ | remote.screen.paired |
remote.paired.reveal-menu |
Alpha | F-RE-9 | spec | — |
remote.paired.reveal-menu |
menu | Show leaderboard / answer / next element | reveal toolbar | inline | Alpha | F-RE-9 | spec | — |
remote.paired.toolbar.lock |
toolbar | Lock / unlock input | remote.screen.paired |
inline (toggle) | Alpha | F-RE-9 | spec | — |
remote.paired.toolbar.timer-extend |
toolbar | + 10 s | remote.screen.paired |
inline | Alpha | F-RE-9 | spec | — |
remote.paired.toolbar.timer-skip |
toolbar | Skip timer | remote.screen.paired |
inline | Alpha | F-RE-9 | spec | — |
remote.paired.toolbar.score-overrides |
toolbar | Score overrides… | remote.screen.paired |
remote.paired.score-overrides |
Alpha | F-RE-9 | spec | — |
remote.paired.score-overrides |
panel | Score overrides | toolbar | inline | Alpha | F-RE-9 | static | remote/score-overrides.html |
3.3 Paired-screen menu
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.paired.menu |
menu | ⋯ | remote.screen.paired |
remote.menu.paired.* |
MVP | — | spec | — |
remote.menu.paired.unpair |
submenu | Unpair from this Host | paired menu | remote.screen.discover |
MVP | — | spec | — |
remote.menu.paired.settings |
submenu | Settings | paired menu | remote.dialog.settings |
MVP | — | spec | — |
remote.menu.paired.about |
submenu | About | paired menu | remote.dialog.about |
MVP | — | spec | — |
4. Reconnect + error surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.notification.reconnecting |
notification | Reconnecting to Host… | paired | inline / remote.screen.reconnect |
MVP | F-RE-8 | spec | — |
remote.notification.host-gone |
notification | Host gone | paired | remote.screen.discover (after timeout) |
MVP | F-RE-8 | spec | — |
remote.notification.session-ended |
notification | Session ended on Host | paired | remote.screen.paired (idle) |
MVP | F-RE-3 | spec | — |
5. Settings + ancillary
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.dialog.settings |
dialog | Settings | app menu | inline (haptics, screen-stays-on, accessibility) | MVP | — | static | remote/settings.html |
remote.dialog.about |
dialog | About | app menu | inline | MVP | — | static | remote/about.html |
6. Gaps + decisions to make
Mockup priorities:
- Paired screen layout — mirror size vs host-notes prominence vs nav-button reach for one-handed use. Phone is the primary form factor; tablet layout is secondary.
- Pair-method-picker UX — single screen with both Scan + Enter Code on it, or a swipeable tab? Default-focused option?
- Rich control affordances — Alpha-phase but worth specifying before code lands so the layout reserves space.
- Reveal menu — what reveals are slide-generic vs object-type-specific.
- Gesture conventions — swipe-left / swipe-right vs button-only. Must not accidentally fire while walking.
Remote flows remote
Surface-to-surface navigation for the Remote. Each diagram shows the happy path forward only — same-screen actions (advance, swipe, refresh, app menu) live in prose under the diagram. Authoring conventions and theme tokens live in .claude/skills/mermaid-diagrams/SKILL.md.
Surfaces in Remote surfaces. FR in Functional Requirements — Remote.
1. App lifecycle
Mockups: Splash · Discover · Pair pending · Paired · Reconnecting
Off-path returns (each surfaces as a banner / inline error, no separate node): pair rejected / timeout → Discover; host disconnect → Reconnecting → Paired on reconnect, → Discover on timeout; user unpairs from paired menu → Discover.
2. Discover and pair
Mockups: Discover · Pair method · Pair pending
On-screen actions: pull-to-refresh; app menu (Settings / About). Code-entry error: "code didn't match" surfaces as an inline banner — stays on the code-entry screen. Pair pending failure: host rejects / timeout banner → Discover.
3. Paired — live control
Mockups: Paired · Scores detail · Jump to slide · Score overrides · Reconnecting
mirror · notes · HUD · nav] PAR --> FS[Fullscreen mirror] PAR --> SD[Scores detail] PAR --> JTS[Jump to slide — Alpha] PAR --> SO[Score overrides — Alpha] PAR --> RM[Reveal menu — Alpha]
Inline controls (no navigation — buttons fire commands, mirror keeps updating): Advance / Back; swipe-left / swipe-right; Lock / unlock input (Alpha); + 10 s, Skip timer (Alpha). App menu: Unpair (→ Discover) / Settings / About. Connection drop: Reconnecting banner over Paired; auto-retry restores Paired, falls back to "Host gone" → Discover.
See also: Remote surfaces · Networking · Functional Requirements — Remote.
Phases
MVP
Goal: Prove the production architecture end-to-end on every supported platform, with the smallest feature set that still constitutes a working live quiz. The output is a thing an author can use to design a quiz and run it for one or more teams in a room — no media, no minigames, no polish, but real.
This is the architecture. Nothing here is throwaway prototype code; the choices made in MVP carry through to Alpha, Beta, and Production.
In scope
Architecture and platform
- The Designer (Tauri 2 + Angular 21 workspace) and the four Unity projects (Host, Client, Remote, Quiz.Preview) initialised with the shared C# class library wired into the Unity apps via local UPM packages and the cross-language contract published as JSON Schema under
schemas/with codegen producing TypeScript types for the Designer — see Repository Layout. - DOTween installed across all four Unity projects.
- Azure DevOps Pipelines running per-area pipelines on Microsoft-hosted + self-hosted Mac mini agents — Unity builds (4 projects), Tauri Designer build (Windows installer + macOS DMG), schema codegen, Angular Jest + Playwright suites, C# analysers,
dotnet format, tests on every PR + push tomain. - Pre-commit hooks for format and analyzer.
- ADO Repos + Wiki provisioned per Build Plan — Azure DevOps repos + wiki setup. No Boards / work-item usage — the build plan itself is the task list.
- Cross-platform from day one. MVP must validate on:
- Designer: Windows + macOS — single Tauri 2 + Angular codebase, desktop only. Host: Windows, macOS, iPad, Android tablet. (iPad / Android tablet authoring is Stretch, not MVP.)
- Client: iPhone, Android phone, iPad, Android tablet.
- Remote: iPhone, Android phone, iPad, Android tablet.
Networking and transfer
- In-process WebSocket server in the Host — see Tech Stack.
- Bonjour/mDNS service discovery — Host advertises, Designer and Client discover. See Tech Stack and Networking.
- Designer→Host
.quiztransfer over the local network — see Transfer Protocol. - Host UI gates incoming transfers with manual confirm per push — every incoming transfer prompts the operator before being accepted.
- Eager push: Host pushes Client-canvas content + Client resources for the entire quiz to each Client on join. Late-joining Clients see a progress UI and jump directly to the current slide on completion.
- Host is authoritative on timer state; Clients render a local countdown that reconciles with the Host's tick. Time-up emits a "lock" message; late submissions are rejected unless the slide's author-configured rule allows them with a scoring penalty. The host operator can override the timer live (extend, skip, manually lock/unlock).
Schemas and the object-type plugin contract
- Quiz manifest, slide, canvas, and element schemas in the shared class library — see Quiz Package Format.
- Round (slide-grouping) schema.
- WebSocket message envelope schema with extension hooks.
- Schema version field on quiz packages, validation on load.
- Object-type plugin contract per Object-Type Architecture:
IObjectType, Designer editor surface, Host runtime surface, Client runtime surface, optional protocol extension. - Built-in registry per app — types register themselves on startup.
Built-in object types (MVP cohort)
| Type id | Notes |
|---|---|
core.text |
Either canvas. Reference implementation of the plugin contract. |
core.multiple-choice-input |
Client canvas. Tap to submit one option. |
core.free-text-input |
Client canvas. Text-entry, submit answer. |
core.numeric-input |
Client canvas. Numeric validation; closest-wins scoring; essential for tiebreakers and estimation rounds. |
core.timer |
Either canvas. Countdown / elapsed. Foundational to most quiz formats. |
core.leaderboard |
Either canvas. Per-team standings. |
The other seven object types (core.image, core.audio-clip, core.video, core.drawing-input, core.buzzer-input, core.categories-input, core.mini-game) are deferred to Alpha or Beta.
Designer behaviours
F-DE-2 to F-DE-14, F-DE-18, F-DE-19, F-DE-20: create quiz, slides, rounds; place / move / configure elements on both canvases; object-type palette listing the MVP cohort; embedded preview; per-slide and per-round timing and scoring; per-slide host-notes; per-element reveal trigger; save and re-open .quiz files; discover Hosts and push over LAN; canonical .quiz package format.
Out: F-DE-1 (cloud auth), F-DE-15/16 (cloud save/version/trash), F-DE-17 (stylus). All are stretch.
Host behaviours
F-HO-3 to F-HO-17, F-HO-20, F-HO-21: WebSocket server + Bonjour; receive Designer push; load .quiz from disk; offline; resolve object-type registry; start session; join screen showing connected teams; eager push on join; advance through slides; receive answers, score, leaderboard; handle disconnect/reconnect; timer-authority and live override; pair with a Remote and stream mirror + host-notes + state; accept advance/go-back commands from the Remote.
Out: F-HO-1 (auth), F-HO-2 (cloud library), F-HO-24 (rich Remote command set — Alpha).
Client behaviours
F-CL-1 to F-CL-9, restricted to the MVP object-type cohort. One shared device per team; team enters team name; receive eager push; render Client canvas; submit multiple-choice and free-text answers; show team score; reconnect.
Out: drawing input, buzzer input, mini-games — those object types come in Alpha or Beta.
Remote behaviours (minimum viable controls)
F-RE-1 to F-RE-8: the minimum viable Remote controller. Discover Hosts; pair with one; open the control-message-family WebSocket; render a live mirror of the Host's display; show per-slide host-notes; show live session state (scores, timer remaining, slide index); send the core navigation commands (advance, go-back); reconnect after Wi-Fi blips.
Out: F-RE-9 — the rich control command set (jump-to-slide, trigger reveals, lock/unlock Client input, extend/skip timer, override scoring per team) — lands in Alpha alongside F-HO-24. Latency / soak verification of the Remote control loop is also Alpha.
End-to-end vertical slice
- Automated end-to-end test: Designer authors a quiz (with host-notes) → Designer pushes to Host over LAN → Host loads → Clients join (eager push) → quizmaster pairs a Remote → quizmaster advances slides from the Remote → answers tally → leaderboard finalises.
- Manual playthrough of a multi-round quiz (text + multiple-choice + free-text + timer + leaderboard) on a real Wi-Fi network with the quizmaster walking the room with a paired Remote.
Explicitly not in scope
- Media: image, audio, video object types.
- Drawing input, buzzer input.
- Mini-games and the mini-game framework.
- Animated reveals, transitions, big-reveal stings — visual/motion polish is Alpha and Beta.
- Cross-cutting design language application — typography/palette/motion vocabulary per Design Specification is applied in Beta.
- Stylus support — touch only on every platform.
- Cloud authoring, accounts, version history.
- Any non-functional target requiring real-device perf measurement (those tighten in Alpha and Beta).
- Sign-in flows, app-store distribution, privacy policy, store listings — that's Production.
Acceptance criteria
- Every functional requirement listed under "In scope" passes its automated test.
- The end-to-end vertical slice test runs green in CI on Windows and macOS Designer + Host targets, and runs manually on iPad and Android tablet for Host, plus the four Client platforms and the four Remote platforms.
- A real pub-style quiz of ≥3 rounds, ≥15 slides total, with text + multiple-choice + free-text questions plus a timer and a leaderboard, can be authored in the Designer (including host-notes per slide) and run end-to-end with two or more Client devices and a paired Remote driving advance/go-back.
- The plugin contract is exercised by all five MVP object types, and adding a sixth object type does not require core-code changes (validated by the first Alpha object type).
- No file-system, network, or schema operation depends on cloud or auth.
- The Remote app's minimum viable controls are functional: discovery, pairing, mirror, host-notes display, live state, advance/go-back. The rich command set (F-RE-9 / F-HO-24) is Alpha — the WebSocket control message family must, however, leave room for it without core-code rework.
How success is measured
- Internal demo of the end-to-end slice on every supported platform.
- Time to author a 15-slide quiz from a blank Designer is under one focused sitting.
- A new object type can be added to the codebase by following the plugin contract alone, without touching slide or session code.
Alpha
Goal: Internal testing builds capable of hosting a full quiz. Every built-in object type is in place except the mini-game framework. The Remote app — already shipping in MVP with minimum viable controls — gains its rich control command set. Crash recovery becomes operational. Quality is "developer-acceptable" — the visual/motion polish, cross-cutting design language, and final brand treatment still come in Beta.
The scope shift from MVP is breadth and operational hardening: more object types, richer Remote controls, and the persistence/recovery work that makes a session safe to run for real.
In scope
Object types added in Alpha
| Type id | Why it lands here |
|---|---|
core.image |
Static media is the lowest-risk media type; unlocks image-based rounds. |
core.audio-clip |
Music rounds are a defining live-quiz format; pairs naturally with core.free-text-input and core.multiple-choice-input. |
core.video |
Builds on core.audio-clip's media-loading work; less common than audio for live quizzes but completes the media set. |
core.drawing-input |
First object type to introduce a Client→Host live mirror channel; protocol-extension exercise. Touch-only in v1 — stylus support is Stretch. |
core.buzzer-input |
First-press semantics across multiple Client devices; tests fairness and the message round-trip target. |
Each object type implements the full plugin contract (schema, Designer editor surface, Host runtime, Client runtime, optional protocol extension) and ships with an end-to-end test exercising a slide that uses it.
Remote rich control commands
The Remote app itself ships in MVP with minimum viable controls (F-RE-1 through F-RE-8 — discovery, pairing, mirror, host-notes, live state, advance / go-back, reconnect). Alpha layers on the rich control command set — F-RE-9 and the matching F-HO-24:
- Jump to a specific slide (random access, not just sequential).
- Trigger element reveals (e.g. show the leaderboard, show the answer).
- Lock or unlock Client input.
- Extend or skip the timer.
- Override scoring per team.
These are the controls that turn the Remote from "advance the deck" into "fully run the room from your pocket". They land in Alpha because each one cuts across non-trivial Host state (timer authority, scoring rules, element reveal triggers) and benefits from the operational hardening that lands alongside (crash recovery, soak testing).
Designer additions for Alpha
- The object-type palette grows to include the Alpha cohort.
- The media-attachment plumbing (F-DE-8) becomes meaningfully exercised by
core.image,core.audio-clip,core.video. - Team-customisation asset bundling (F-DE-28, F-DE-30) — author attaches buzzer jingles + premade-avatar set to the
.quizunderresources/audio/buzzers/andresources/avatars/. Designer ships a small built-in default asset library of pre-licensed jingles + starter avatars; selections are copied into the active.quizso the package stays self-contained (F-DE-29).
Team customisation at join
Lands alongside core.buzzer-input since the buzzer-jingle pick only makes sense once buzzer slides are playable, and the photo + avatar palette gives the leaderboard something to render the moment any Alpha element runs:
- F-CL-14 — captain takes a photo (or picks from the quiz's bundled premade-avatar set) at join. Camera permission, capture, crop-to-square, JPEG-encode ≤ 64 KB / 256 × 256 px on-Client before upload.
- F-CL-15 — captain picks a buzzer jingle from the quiz's bundled set at join. Preview-on-tap before commit. Picker hides if the quiz bundles no jingles.
- F-HO-26 — Host renders the team photo on leaderboard rows + per-team callouts.
- F-HO-27 — Host plays the per-team buzzer jingle on
core.buzzer-inputfirst-press wins. Operator can mute jingles globally without ending the session.
The team-photo binary travels in the join WebSocket message and lives in the session snapshot for crash recovery; it's cleared at session end (Networking — Team join).
Crash recovery (operational hardening)
This is the load-bearing piece of Alpha — see Networking — Crash recovery and the new requirements:
- F-HO-18: Host persists session state to disk after every scoring event and every slide advance.
- F-HO-19: Host on launch with a saved session prompts to resume or start fresh; resume restores slide pointer, team list, scores, per-element state.
- F-CL-10: Client persists its team identity locally; reconnects to a recovered session as the same team.
The reconnection protocol path is the same one used for Wi-Fi-blip recovery — only difference is whether the Host's state was rebuilt from disk or in-memory. This means the Wi-Fi-recovery work in MVP and the crash-recovery work in Alpha are largely the same code path, with Alpha adding the on-disk snapshot.
Animation and reveal beats
- Animated reveals on Host slides (F-HO-13) — first pass, not yet polished to brand spec.
- Leaderboard reveal animations.
- Question-transition motion budget verified informally (formal target lands in Beta).
Reliability and performance
- Reliability soak test: client disconnect storms, host backgrounding, Wi-Fi flap, simulated Host crash + recovery.
- Performance: 60 fps animation budget verified on iPhone 12 / equivalent Android (measured in the Unity runtime build) per Non-Functional Requirements.
- Element answer-submit round-trip latency < 200 ms on local Wi-Fi (verified).
- Remote control-message round-trip latency < 200 ms on local Wi-Fi (verified).
Documentation
- Object-type plugin contract is documented in Object-Type Contract (alongside the narrative Object-Type Architecture); Alpha verifies the contract is sufficient for the new cohort and folds any learnings back into that page.
- Remote control-message family is documented in Live Play Protocol; Alpha verifies the Alpha rich-command set is fully specified and exercised end-to-end.
Explicitly not in scope
- Mini-games and the mini-game framework — Beta.
- Brand-true motion language and full Design Specification treatment — Beta.
- App-store distribution, store listings, privacy policy — Production.
- iPad / Android tablet Designer authoring — Stretch.
- Stylus support — Stretch.
- Multi-Remote support — Stretch, if ever.
- Any cloud or auth.
Acceptance criteria
- A full live quiz spanning all Alpha object types can be authored, transferred, and run end-to-end without crashes.
- A team can join with a photo (or a premade avatar fallback) and a chosen buzzer jingle; the photo renders on the leaderboard and the jingle plays on the Host when that team wins a buzzer press.
- Reliability soak test passes its acceptance thresholds (no Host crash through a 90-minute session of disconnect storms).
- A simulated Host crash mid-session is recoverable: Host relaunches, the operator chooses "resume", Clients reconnect as the same teams with their scores intact.
- The Remote app, paired with the Host, can drive a full quiz session from the quizmaster's phone using the rich command set — including jumping to a specific slide, triggering a leaderboard reveal mid-slide, and overriding a scoring decision live (all of which build on the MVP advance/go-back baseline).
- Latency targets verified on at least one Client / Host pair and one Remote / Host pair on local Wi-Fi.
- Internal stakeholders (Quiz UK) play a full quiz session and the result is "this could become a product."
How success is measured
- Full internal playthrough — the team can run a quiz night using the platform without manual intervention beyond authoring.
- Bug count after a representative session converges to a fixable list, not a system-level rethink.
- The quizmaster's experience walking the room with the Remote feels right (no constant glances back at the Host).
Beta
Goal: A polished build that Quiz UK can run with real teams in a real venue. Mini-games are introduced. The platform looks and moves like the Design Specification describes, not like a developer build.
The scope shift from Alpha is polish + mini-games — the loud, performative parts of the experience that the brand identity promises.
In scope
Mini-game framework
core.mini-gameelement implementing the full plugin contract.- Mini-game framework as a separate concern within the Client and Host (lifecycle, scoring hand-off, presentation overlay).
- Three mini-games ship as built-ins, registered against the mini-game registry resolved by
core.mini-game— see Object Types — Mini-game built-ins for inspector-configurable fields per mini-game: mini-game.internal-clock— judge-the-time press; closer to a configurable target scores more, overshoot scores zero.mini-game.team-shoot— first-press-wins between a named pair (or the next-fastest team); wrong-team presses lose configurable points.mini-game.spin-wheel-modifier— post-answer scoring flourish; lands on a± valuesegment that applies to the configured target teams.
Question-type cohort
Three additions land in Beta, each implementing the full plugin contract:
core.categories-input— a defining live-quiz format ("Name 5 X") with multi-line entry and per-line scoring.core.image-reveal-input— image obscured by a configurable shader filter that clears over the question duration; multiple-choice answer underneath; earlier-correct scores more via a configurable speed-bonus curve.core.word-scramble-buzzer— animated scrambled-letter tiles on the Host; buzzer-style first-press submission on the Client; first correct team scores; wrong-attempt penalty + per-team cooldown configurable.
Beta also extends core.multiple-choice-input with two optional inspector-configurable behaviours that default off: an elimination schedule (incorrect options drop on a timeline) and a speed-bonus curve (more points for earlier correct submissions).
Cross-cutting design language
The full Design Specification is applied across Designer, Host, Client, and Remote:
- Brand palette and gradients.
- Typography per the Design Specification.
- Motion vocabulary — confident/snappy spring motion, big-reveal beats, mascot animation.
- Iconography, layout, spacing scale.
- Voice and tone in copy.
This includes Quiz UK validation — the mascot, branding, and final brand name are confirmed and applied.
Theme system
Two layers of theming, both Beta:
- Designer chrome theme — author preference, light or dark, persisted in app settings (F-DE-25). Authors who spend long sessions in the Designer get a calmer light option.
- Per-quiz theme on Host / Client / Remote — the quiz manifest declares a theme that the playback apps render against (F-DE-26, F-X-4). Default is the dark brand theme.
The two layers are independent. Richer per-quiz custom palettes are Stretch.
Sound design
Full audio language ships in Beta (F-X-5): branded stings (correct / incorrect / lock / time-up / big reveal / end of round / end of quiz), transition motifs, and an optional ambient music bed. Audio is mixed for the venue — stings cut through pub ambient noise without being intrusive. Licensing/sourcing decisions belong to Beta-phase work.
Mascot animation rig
The mascot is rigged once (a single Unity-native rig in the shared assets package) and reused across the Unity apps — Host, Client, Remote — plus the Quiz.Preview canvas embedded in the Designer. Each app picks the appropriate pose from a shared animation library — waving on welcome, cheering on big reveals, sleepy on end-of-quiz. The Designer chrome itself (outside the preview canvas) reuses the mascot via an exported still-frame or Lottie/Rive animation derived from the same rig so brand consistency holds without bundling Unity inside the Tauri shell.
Per-team theming
A team picks a colour and avatar at join (from a fixed author-configured palette inside the quiz, so brand consistency is preserved). The choice carries through every Client surface, every leaderboard row, and any Host moment that renders per-team identity.
Visual flair beyond the team name. Lands here because Beta is the polish phase — not in MVP/Alpha (where it's deliberately utilitarian), and not Stretch (because it doesn't depend on cloud and adds meaningful UX value to a full Beta playthrough).
Real-device validation
- All target platforms tested on real hardware. Test devices needed: a modern iPhone, an old iPhone, a modern iPad, an old iPad, a modern Android phone, an old Android phone, a modern Android tablet, an old Android tablet. Older devices catch performance regressions on lower-end hardware that the dev workstation hides.
- Performance and latency targets re-verified on the older devices, not just the developer workstation.
- Hardware/OS minimum versions confirmed against Unity's Editor and Player support matrix (Host / Client / Remote / Quiz.Preview), Tauri 2's supported-platform matrix (Designer), and against real-device behaviour. The provisional minimums in Non-Functional Requirements become final at the end of Beta.
Quiz UK pilot
- Quiz UK runs at least one real live quiz night using the Beta build.
- Feedback loop captured (open-questions, bug list, feature requests).
Explicitly not in scope
- App-store distribution, store listings, privacy policy — Production.
- Sign in with Apple — Production, only if iOS App Store distribution is chosen.
- iPad / Android tablet Designer authoring — Stretch.
- Cloud-backed authoring, accounts, libraries — Stretch.
- Stylus support — Stretch.
- The remaining Stretch-tier question type (hotspot) — Stretch.
- AI-aided quiz authoring, broadcast view, recurring teams, tournaments, templates, question bank — Stretch.
Acceptance criteria
- A real Quiz UK pub night runs successfully on the Beta build.
- The Designer, Host, Client, and Remote each visually match the Design Specification on every supported platform (cross-platform polish is part of Beta, not deferred to per-platform).
- Mini-games run in-session without breaking the per-slide round-trip latency budget.
- Performance budget (60 fps; transition < 500 ms; round-trip < 200 ms) holds on the older test devices.
How success is measured
- Quiz UK is willing to run more nights with the Beta build.
- Real participants don't notice it's a beta.
Production
Goal: A launch build distributed through real channels to real users — quizmasters beyond Quiz UK. Polish is locked; the remaining work is the commercial and operational layer.
The scope shift from Beta is launch readiness: distribution, identity, store listings, privacy, and operational posture.
In scope
Distribution
Confirmed distribution channels:
- iOS App Store — required for any iPhone/iPad distribution. Triggers Sign in with Apple obligation only when cloud auth ships in Stretch; v1 has no auth.
- Google Play Store — required for any mainstream Android distribution.
- Microsoft Store — primary discoverability channel for Windows. Sideloaded
.exe/.msimay be considered later for power users. - macOS App Store / direct DMG — both. App Store buys discoverability and one-click install; direct DMG is the lower-friction path for users who prefer to download from the project site.
Each channel adds work for: signing, packaging, submission flow, and update mechanism.
Identity and policy
- Sign in with Apple wired up if iOS App Store cloud-backed auth (a Stretch goal) ships first. Apple requires Sign in with Apple only when other social-auth providers are offered, so it's only relevant when cloud auth lands. v1 ships no auth on any app.
- Privacy policy drafted and published.
- Store listings drafted (descriptions, screenshots, age rating, accessibility statements).
Hardware and OS minimums
The provisional minimums in Non-Functional Requirements are finalised in Beta, not Production. By Production they are locked. Production work is to ensure each store listing accurately reflects the locked minimums and that the app refuses to run (with a clear message) below them.
Operational
- Performance and latency targets re-verified on the production builds, not just the development builds.
- Crash reporting / telemetry decisions made (consistent with the privacy posture in v1 — see Non-Functional Requirements).
Explicitly not in scope
- Cloud-backed authoring, accounts, libraries — Stretch.
- Bundle-supplied object types — Stretch.
- Internet-based live play — Stretch.
- iPad / Android tablet Designer authoring — Stretch.
- Anything in the Stretch list.
Acceptance criteria
- A quizmaster who is not Quiz UK can install, author, and run a quiz on every supported channel (iOS App Store, Google Play, Microsoft Store, macOS App Store, macOS DMG).
- All store reviews / approvals pass.
- Performance and latency targets hold on the production builds, not just the development builds.
- Privacy policy and store listings are live and accurate.
- The app refuses to install or shows a clear "device not supported" message on devices below the locked OS minimums.
How success is measured
- Real third-party quizmasters (beyond Quiz UK) successfully run quiz nights using the production build.
- Crash-free session rate above an agreed threshold across the supported devices.
Process
Project Management
The Build Plan is the single source of truth for tasks. Each - [ ] bullet is a unit of work; ticking it to - [x] marks completion. There is no external work-item tracker mirroring the build plan. The build plan is split into one file per app plus a cross-cutting project file.
Azure DevOps still hosts the code repository, the CI / CD pipelines, and the auto-mirrored wiki — but ADO Boards / Stories / Tasks are not used.
Workflow from PRD sign-off to delivery
- PRD sign-off. The PRD is the locked-in requirements artefact, generated from the knowledgebase by the
docsskill. Sign-off ends "what are we building" debate; subsequent changes go through a controlled change process (re-edit the knowledgebase, regenerate, re-sign-off). - Work the build plan. Pick the next unticked bullet that has no unmet dependency, implement it under TDD per Testing, tick it
[x]on completion. - Task-completion loop fires. Whenever a chunk flips one or more bullets, the CLAUDE.md task-completion workflow runs: commit + push the chunk, regenerate docs, commit + push the regenerated HTML, publish the docs site.
- Iterate. No sprint ceremonies, no standup, no estimation. Phase tags on each section (
[MVP]/[Alpha]/[Beta]/[Production]) define delivery cadence; the build plan is worked top-down within each phase.
Adding work
New scope is added by editing the build plan in place — append a - [ ] bullet under the right section, or add a new ## section if the work is structurally new. Regenerate the docs site after the edit so the published task list stays current.
If the work changes the spec (not just implementation), update the knowledgebase first per the knowledgebase skill conventions, then add the build-plan bullets that implement the change.
Definition of Done
Every bullet meets the build plan's preamble Definition of Done before the box is ticked:
- TDD followed (failing test first, then implementation, then refactor) per Testing.
- End-to-end automated test exists for any user-facing feature the bullet touches.
- Lint and format pass; coding standards followed.
- UI changes additionally pass visual verification against the matching mockup — see CLAUDE.md — UI work — visual verification before sign-off.
Reference
Open Questions
Decisions deferred until more is known. Each entry says what is open and the reason it is being deferred.
Each entry is tagged with its status:
- Prototype-deferred — answer comes out of the load-bearing prototype work. No user-input decision needed today.
- Phase-deferred — answer is committed to a future phase (most often Beta or Production) where the work belongs.
- Open — needs a decision, currently waiting on external input or a deliberate timing.
-
Bundle-supplied object types (Phase-deferred — Stretch). v1 ships built-ins-only;
.quizpackages contain no runtime C# code. A future stretch goal — alongside cloud-backed authoring — is to allow object types bundled inside a.quizpackage as additional C# behaviours + prefabs. That brings real questions: signing/verification of bundled modules, sandboxing their C# behaviours, the author-facing UX for installing third-party object types, and whether the loader is exposed to end users at all. Tracked in Stretch. -
Internet-based live play relay (Phase-deferred — Stretch). Cloudflare Durable Objects are the leading candidate but the protocol design and discovery mechanism are not yet specified. Tracked in Stretch.
-
Cross-quiz analytics (Phase-deferred — Stretch). Whether and how to record quiz session data for the author's later review (e.g. "which slides were hardest?"). Privacy posture would need to be designed alongside. Tracked in Stretch.
-
Final brand and product name (Phase-deferred — Beta). The apps are referred to by their engineering project names (Quiz.Designer, Quiz.Host, Quiz.Client, Quiz.Remote) through MVP and Alpha. The Quiz UK pilot in Beta is the moment to lock the final brand and product name, alongside store-listing prep and brand-true polish. See Design Specification.
-
Web Designer hosting model (Phase-deferred — Stretch). The Web-based Designer is a future-release Stretch. Hosting model — static CDN bundle vs Angular SSR — is decided when the Stretch is promoted. The Angular workspace itself is shell-agnostic behind the
PlatformAdapterinterface, so the port lands aBrowserPlatformAdapterplus the chosen hosting target rather than a frontend rewrite. -
Server-backed persistence vendor (Phase-deferred — Stretch). The Designer's
PersistenceService/LibraryService/TransferServiceAngular services abstract over aPlatformAdapterso the Tauri shell binds local-disk + mDNS implementations today. The eventual Web-based Designer Stretch lands HTTP-backed implementations against a server backend; the backing vendor (Postgres + S3-compatible vs platform-specific managed services like Cosmos / Blob Storage, or Supabase / Firebase) is deferred until the Stretch is promoted and the cloud-hosting target is picked.
Glossary
| Term | Definition |
|---|---|
| Author | The person who creates a quiz using the Designer. |
| Designer | The authoring app, used on Windows or macOS desktop. (iPad / Android tablet authoring is Stretch.) |
| Host | The app that runs the live quiz session, typically on a device connected to a projector or TV. Runs on Windows, macOS, iPad, and Android tablet. |
| Client | The team app, used on iPhone, Android phone, iPad, or Android tablet. One shared device per team. |
| Remote | The quizmaster's pocket controller, runs on phone or tablet. Pairs with one Host; mirrors the Host display, shows the per-slide host-notes, and sends control messages. Ships in MVP with minimum viable controls (advance / go-back); the rich control commands (trigger reveals, lock/unlock input, extend/skip timer, override scoring, jump-to-slide) land in Alpha. |
| Quizmaster | The person running the live session at the venue. Operates the Host directly and optionally a paired Remote. |
| Host-notes | Free-form per-slide text the author writes in the Designer for the quizmaster — hints, answer keys, presentation cues. Visible only on the Remote during play; never on the Host canvas or any Client. |
| Participant / Quiz Goer | A person playing the quiz. v1 has no per-individual representation; participants are organised into Teams that share a Client device. |
| Team | The unit of play in v1. One Team = one shared Client device + one team name. All scoring, answers, and standings are per-Team. Typical session: ~30 teams. Maximum: 200 teams per Non-Functional Requirements. |
| Slide | The unit of presentation in a quiz, like a PowerPoint slide. Each slide has its own Host canvas and Client canvas. |
| Round | A grouping of contiguous slides sharing a theme, scoring rule, or section title — a "section" in PowerPoint terms. Rounds are optional metadata over the slide list, not the unit of presentation. |
| Host canvas | The TV/projector-facing surface of a slide. A fixed virtual canvas (1920×1080 baseline) that scales to fit the connected display. |
| Client canvas | The phone/tablet-facing surface of a slide. A responsive layout (anchors / regions / stacks) that adapts across phone, tablet, portrait, and landscape. |
| Element | A placed instance of an object type on a canvas, with its own per-instance properties. |
| Object type | A self-contained module that defines an addable element — its schema, Designer editor surface, Host runtime surface, Client runtime surface, and any messages it exchanges. New object types can be added without modifying core code. See Object-Type Architecture. |
| Round type | An informal term for a recognisable composition of slides and object types (e.g. "music round", "drawing round"). Not a load-bearing schema concept under the slide model — recipes, not primitives. |
Quiz package / .quiz file |
Zip archive containing the manifest, slide definitions, optional bundled object types, and resources for a quiz. See Quiz Package Format. |
| Live play | The act of running a quiz with a Host and connected Clients. |
Stretch
Stretch
Features beyond the initial launch. Worth noting down so the ideas aren't lost, not planned for a specific release. Scope and acceptance criteria for a Stretch item are defined when the item is promoted to a real phase, not now.
Web-based Designer
A browser-hosted authoring app — alternate producer of the same .quiz package format the desktop Tauri Designer writes. Host / Client / Remote are unchanged: they consume .quiz files regardless of which Designer produced them.
Full Stretch. v1 ships the desktop Tauri 2 + Angular Designer only. The web Designer is a future-release goal — not in tree, not partially in tree, no day-one staging. When promoted it builds on top of the same Angular workspace but adds a browser shell, a backend, and a different deployment story.
What promotion lands:
BrowserPlatformAdapter— implements the existingPlatformAdapterinterface against browser APIs. File System Access API for local-disk save / load where supported; fall-back upload / download elsewhere. No mDNS — Host discovery is manual IP entry or host-issued QR / pairing code. No subprocess spawn —Run from slidefalls back to pushing the in-memory.quiz+ a start-slide control envelope to an already-running Host instance.- Hosting target — Angular bundle served as a static site behind a CDN, or via Angular SSR if first-paint matters. Pick at promotion.
- Auth — OAuth → JWT, multi-tenant identity.
- Persistence backend — server-backed
PersistenceService(Postgres for manifests + S3-compatible blob store for resources). - Library backend — per-account
LibraryServicebacked by the same blob store; the local-disk dedup-by-hash semantics adapt cleanly. - Transfer backend — relay-routed transfer for cross-network play; LAN push uses host-issued pairing codes since browsers cannot do mDNS.
- Quiz.Preview CDN delivery — the same WebGL bundle the Tauri Designer ships today, served from CDN with correct CORS + brotli/gzip + lazy-load until the preview pane opens.
- Routing + auth gates — login → quiz library → editor.
Code reuse from the Tauri Designer:
- The Angular workspace itself — every authoring component runs unchanged because the WebView already runs web-tech.
AuthoringSession,CommandDispatcher, the service layer — same TypeScript code; promotion swaps thePlatformAdapterimpl.schemas/codegen — TypeScript types +ajvvalidators shared across both shells.
The reuse story is the load-bearing reason to keep PlatformAdapter and friends in place even though v1 only ever ships the Tauri impl — it keeps the eventual web port additive rather than a rewrite.
Open questions for promotion (see Open Questions):
- Hosting model (static CDN bundle vs Angular SSR) and backend vendor (Postgres + S3-compatible vs platform-specific managed services).
- Browser-shell discovery UX — manual IP entry vs host-issued QR / pairing code as the primary path.
Run from slideUX on web — companion launcher tray app vs "install the Tauri Designer when you need it" vs always-push to a running Host. Likely the last.
Risks specific to this stretch:
- Cloud infrastructure scope — Postgres, k8s / managed runtime, CDN, ops on-call. Material commitment; gates the stretch promotion.
- Auth / multi-tenant SaaS plumbing — OAuth → JWT, RLS or app-layer tenant isolation.
- Real-time collab (optional) — CRDT layer (e.g. Yjs) if multi-user editing is required. Defer unless asked for.
- WebGL bundle size in browser — 20-50MB initial download; CDN + brotli/gzip + lazy-load until preview pane opens.
- Asset bundle CORS / signed URLs — backend serves them with correct headers and per-tenant access controls.
- Browser compat — Unity WebGL safest on Chromium; Firefox / Safari work with caveats.
- File System Access API browser support — strong on Chromium, missing on Safari; design fall-back to upload/download for Safari users.
Spikes (when stretch is promoted):
BrowserPlatformAdapter— File System Access API integration; QR / pairing-code Host discovery flow; verify the Angular workspace runs unchanged inside a plain browser tab.- Backend skeleton — API + Postgres + CDN + CI deploy.
- Server-backed
PersistenceServiceandLibraryService— replace the Tauri impls with HTTP-backed ones. - Auth + multi-tenant story — OAuth login, per-tenant
.quizstorage, isolation enforced. Quiz.Preview(WebGL) loaded from CDN with signed URLs; JS bridge round-trip confirmed in the production shell.
Cloud-backed authoring
The single biggest stretch goal. Adds a cloud account with authentication and storage — quizmasters sign in, save quizzes to the cloud, get a versioned library, and download quizzes onto a Host. Vendor (managed Postgres + S3-compatible object storage) decided when this stretch is promoted; the architecture stays vendor-neutral until that point.
- Provision the cloud backend (vendor TBD).
- Account / profile + quiz library data store with per-tenant isolation enforced (RLS or equivalent).
- Quiz blob storage with per-owner isolation.
- Version history with pruning policy (latest N + pinned).
- Sign-in on Designer (F-DE-1) and Host (F-HO-1) per Authentication.
- Designer: save quiz to cloud, version history, restore (F-DE-15).
- Designer: soft-delete + trash view (F-DE-16).
- Designer: pin a version to protect from pruning.
- Host: list quizzes from the cloud library, mark which are downloaded (F-HO-2).
- Host: download and cache a quiz package from the cloud (F-HO-2).
Bundle-supplied object types
- Loader for object types bundled inside a
.quiz(signing, sandboxing, install UX) — see OQ#1. - Author-facing UX for installing third-party object types.
This is gated on cloud-backed authoring landing first because the trust / distribution / install model only really makes sense in a cloud-aware world.
Stylus support
- Apple Pencil pressure / tilt / palm rejection on iPad.
- S Pen, Surface Pen, Wacom on Android / Windows tablets through Unity's Input System.
- Applies to: Designer authoring sketches, embedded preview,
core.drawing-inputelement on the Client.
v1 is touch-only on every platform.
iPad / Android tablet Designer
The Designer ships first-class desktop only in v1 (Windows + macOS). iPad and Android tablet authoring is deferred to Stretch and ships derived from the Web-based Designer codebase — same Angular application wrapped as a mobile app shell (Capacitor or a Tauri 2 mobile target). Drives the iPad / Android tablet authoring path off the same WebGL-in-browser preview the web tool uses. Ships only after the web-based Designer is in place.
Real-time collaborative quiz authoring
Google-Docs-style multi-author editing of a single quiz. Depends on cloud-backed authoring + a CRDT layer (e.g. Yjs equivalent) over the quiz model. Adds significant infrastructure complexity and is deferred until clear demand from quizmasters working in teams.
Public quiz marketplace
A discovery + sharing surface where authors can publish quizzes for others to clone or play. Distinct from internal team sharing: this is public, with reputation, ratings, and possibly paid distribution. Depends on cloud-backed authoring. Deferred until cloud-backed authoring is in place and a clear product proposition forms around third-party sharing.
Per-individual identity within a team
v1 ships one shared Client device per team. Per-individual play (each team member on their own Client, scoring rolls up to the team) is a possible future expansion. Reopening would change the eager-push model and the join screen — not a small change. Worth keeping on the backlog so the idea isn't forgotten.
Multi-Remote (co-quizmasters)
v1 supports zero or one Remote per session. Multiple Remotes paired to the same Host (e.g. a quizmaster and a scorekeeper on separate phones) is a possible future expansion. Adds protocol questions: who can override whom, what happens when two Remotes try to advance simultaneously. Defer until anyone asks.
Cross-quiz analytics
Recording quiz session data for the author's later review — "which slides were hardest?" "which round dragged?" — see OQ#3. Privacy posture would need to be designed alongside the feature.
Internet-based live play
An optional relay (Cloudflare Durable Objects or similar) that proxies WebSocket traffic between Host and Clients in different locations. Same protocol; different transport. Discovery shifts from Bonjour to a host-issued session code (F-X-6). The join / live-play message families are identical between LAN and internet paths; only the transport differs. UI surfaces this brings in:
- Client —
client.screen.code-entrypanel with a chunked code input (Q7-3K9-FX), reachable from the discover screen as an alternative to picking a Host from the Bonjour list. See Client surfaces. - Host —
host.idle.session-codeandhost.join.session-codesurfaces displaying the relay-issued code alongside the LAN QR. See Host surfaces. - Remote — existing pair-method picker (Remote surfaces — §2) gains an internet branch the same way the Client discover screen does.
See Networking — Internet-based play and OQ#2 for the open relay-protocol design questions.
Better Host crash recovery
Crash recovery is in v1 (Alpha phase). Stretch ambition: faster snapshot cadence (per-message rather than per-event), fully resumable timer state including elapsed time during a Host outage, multi-instance Host failover. None of these are required for a workable v1.
Additional question types
The v1 object-type catalogue ships 18 built-ins across MVP, Alpha, and Beta. The candidate below extends the catalogue with one further format that's distinct enough to warrant its own object-type module but didn't make the v1 cut. It implements the full plugin contract.
| Type id | Why it earns its own type | Typical placement |
|---|---|---|
core.hotspot-input |
Tap on image coordinates. "Where on the map is X?" / anatomy / geography. | Client canvas. |
AI-aided quiz authoring
LLM-backed assistance inside the Designer: generate questions on a topic, suggest distractors for a multiple-choice question, propose a difficulty rating, draft host-notes from a question and its answer.
The hard part isn't the integration — it's verification. LLMs hallucinate trivia answers regularly, so the UX must surface the model's confidence, cite sources where possible, and require the author to confirm each generated artefact before it lands in the quiz. Treating LLM output as a starting draft (always edited, never blindly accepted) is the load-bearing UX choice.
Privacy posture decisions: whether prompts and generated content stay local-only, are sent to a third-party API, or run via a local model.
Broadcast / streaming-friendly Host view
A dedicated Host display mode optimised for streaming. The default Host display is designed for a TV in a venue; a streaming view tones down screen-burn elements (no large solid backgrounds), boosts overlay readability, and crops out chrome that's only useful in-room (e.g. join-code prompts after the quiz starts).
Goal: a quizmaster can drive an OBS-quality stream by capturing the Host window directly — no separate OBS overlay, no second-monitor wrangling. Notes from the original proposal review flagged "OBS as a hard requirement" as a barrier; this absorbs that into the Host app.
Recurring teams across sessions
Persistent team identity across multiple quiz nights. A team that played last week shows up tonight with their previous name, colour, and (optionally) a season-long score history.
Depends on:
- Cloud auth (the Quizmaster's account anchors the league of recurring teams).
- A team-identity store keyed by quizmaster + venue.
- Team-claim UX on Client join: "Are you returning team X?"
Privacy and data-protection requirements need design alongside — see OQ#3 for the analogous concern with cross-quiz analytics.
Tournaments / leagues
A series of quiz sessions feeding a season-long aggregate leaderboard. Distinct from a single quiz: needs season schema, cross-session scoring rules, league standings UI on the Designer/Host. Depends on recurring teams and cloud-backed authoring landing first.
Quiz templates / starter packs
A library of pre-built quizzes the author can clone and adapt. Lowers the barrier to a first-night quiz — start from "Live Quiz Classic" rather than a blank slide.
Depends on cloud-backed authoring (templates live in the cloud library; cloning makes a private copy for the author to edit).
Question bank / reusable questions
The author's personal library of questions, decoupled from any specific quiz. A question can be authored once and pulled into any number of quizzes. Each question carries its own answer, scoring rule, and metadata.
Depends on cloud-backed authoring (question bank lives in the cloud library, not a single .quiz package).
Speed-round mode
A round-level config that runs slides back-to-back with very short timers and no inter-slide pause. Different cadence to the default per-slide pacing — pressure-format rounds.
Could be promoted to Alpha as a small addition to the round-level timing schema rather than waiting for Stretch — it's mostly configuration on top of existing timer and round primitives. Logged here so it isn't lost.