Live Quiz Platform

Product Requirements Document

Status Draft
Scope Project (cross-cutting)
Last updated 2026-06-05
Source of truth .claude/context/knowledgebase/

Contents

  1. Overview
  2. Architecture
  3. Requirements
  4. UI surfaces and flows
  5. Phases
  6. Process
  7. Reference
  8. Stretch

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:

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

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:

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:

Client client

A lightweight team app for phones and tablets — iPhone, Android phone, iPad, and Android tablet. One shared device per team. The Client:

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:

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.

%%{init: { "theme": "base", "themeVariables": { "fontFamily": "Inter, system-ui, sans-serif", "fontSize": "14px", "primaryColor": "#FFFFFF", "primaryBorderColor": "#FF009F", "primaryTextColor": "#1F1933", "secondaryColor": "#F0EBE3", "secondaryBorderColor": "#16B2EB", "secondaryTextColor": "#1F1933", "tertiaryColor": "#F8F5F0", "tertiaryBorderColor": "#5A536B", "tertiaryTextColor": "#1F1933", "lineColor": "#5A536B", "edgeLabelBackground": "#F8F5F0", "mainBkg": "#F8F5F0", "clusterBkg": "#F0EBE3", "clusterBorder": "#DDD5C8", "titleColor": "#1F1933", "nodeBorder": "#5A536B" }, "flowchart": { "useMaxWidth": true, "htmlLabels": true, "curve": "basis", "padding": 20, "nodeSpacing": 70, "rankSpacing": 80 } }}%% flowchart TB subgraph v1["v1 — LOCAL"] direction TB DES[Designer
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


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

%%{init: { "theme": "base", "themeVariables": { "fontFamily": "Inter, system-ui, sans-serif", "fontSize": "14px", "primaryColor": "#FFFFFF", "primaryBorderColor": "#FF009F", "primaryTextColor": "#1F1933", "secondaryColor": "#F0EBE3", "secondaryBorderColor": "#16B2EB", "secondaryTextColor": "#1F1933", "tertiaryColor": "#F8F5F0", "tertiaryBorderColor": "#5A536B", "tertiaryTextColor": "#1F1933", "lineColor": "#5A536B", "edgeLabelBackground": "#F8F5F0", "mainBkg": "#F8F5F0", "clusterBkg": "#F0EBE3", "clusterBorder": "#DDD5C8", "titleColor": "#1F1933", "nodeBorder": "#5A536B" }, "flowchart": { "useMaxWidth": true, "htmlLabels": true, "curve": "basis", "padding": 20, "nodeSpacing": 70, "rankSpacing": 80 } }}%% flowchart TB USER([User]) subgraph t1["Tier 1 · Tauri 2 shell — native window + Rust plugins · Windows + macOS (desktop only)"] direction TB TSHELL[Tauri shell
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:

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:

Tier 3 — Contracts + Unity foundation

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.

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.

  1. Tauri opens the splash WebviewWindow (no decorations, no resize, always-on-top) and shows it within ~50 ms of process start. The splash WebView loads splash.html from the bundled Angular assets.
  2. Splash boot tasks run in parallel inside the Angular workspace: schema codegen check (TS validator hash matches the bundled schemas), LibraryService index hydration, recent-files index read, PreviewBridge waits for the bundled Quiz.Preview WebGL build to report ready, mDNS Rust-side handshake (Designer-discoverable false by default — we're not advertising, just initialising the listener).
  3. 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.
  4. When every task resolves, Tauri opens the main shell WebviewWindow at 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.
  5. 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

  1. User opens a .quiz (or starts a new one). Angular PersistenceService reads via Tauri fs + the zip crate; Quiz.Core JSON Schema validators verify on load.
  2. User selects a slide. Angular renders an approximate / form-driven view of the slide for fast editing.
  3. Angular pushes the slide's state (JSON) to Quiz.Preview over the JS bridge. Quiz.Preview renders the slide pixel-accurate (modulo WebGL drift).
  4. User edits a property. Angular updates AuthoringSession via a command (undoable), pushes the delta to Quiz.Preview. WebGL re-renders.
  5. User clicks Push to Host — Angular TransferService calls 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

  1. User picks a file via the platform-adapter's file picker (Tauri dialog).
  2. Designer streams the file through a SHA-256 hasher (Web Crypto API, available in the Tauri WebView).
  3. If index.assets[].sha256 already contains this hash: - Append the imported filename to originalFilenames if absent. - Update lastUsedAtUtc. - Do not copy the file again.
  4. Else: copy the bytes to blobs/<first2>/<rest> via the standard atomic-write protocol (.tmp → fsync → rename), then add an assets[] entry.
  5. Re-write index.json atomically.

Bundle-on-save / unbundle-on-load

When the author saves a .quiz:

  1. The Designer walks every element referencing a library asset.
  2. For each unique sha256, copy the blob's bytes into the .quiz archive's resources/<kind>s/<sha256>.<ext> path.
  3. The element's objectTypeData stores the asset as a bundled-resource id ("resourceId": "<sha256>"), so the saved package is fully self-contained.

When the author opens a .quiz:

  1. The Designer reads each bundled resource's bytes.
  2. For each one, hashes and looks up in the library.
  3. Match → reuse the library blob; the in-memory model rebinds the resource pointer to the library hash. The bundled bytes in the open .quiz are not re-imported.
  4. 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:

  1. Move index.json to index.json.broken.<timestamp>.
  2. 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 }.
  3. Re-write index.json atomically.
  4. 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:

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:

  1. Saves the in-memory quiz to a scratch .quiz (or uses the on-disk path if clean).
  2. Calls the Tauri process plugin: Command::new("Quiz.Host").args(["--quiz", path, "--start-at-slide", index, "--launched-from-designer"]).spawn().
  3. 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.

  1. ng serve (default http://localhost:4200).
  2. python .claude/skills/ui-mockups/scripts/screenshot.py --url http://localhost:4200/ writes a PNG to .build/.
  3. 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:

A single schemas/codegen.config.json declares the inputs, outputs, and naming conventions. Both codegen runs are wired into:

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.

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

com.quiz.runtime

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

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:

  1. Create the Unity project as a sibling at the repo root (Quiz.{Name}/).
  2. Add the three file: package entries to its Packages/manifest.json.
  3. 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):

  1. Create packages/com.quiz.{name}/ with a package.json and Runtime/ (and optionally Editor/ and Tests/) folders, each with its own asmdef.
  2. Add a file: entry to every Unity project's Packages/manifest.json that needs it.
  3. Document the new package's responsibility in this page's "Package responsibilities" section.

Adding a new schema

  1. Add the JSON Schema file under the right schemas/<area>/ directory.
  2. Add it to schemas/codegen.config.json with the desired output names for C# and TypeScript.
  3. Run npm run schema:gen (Angular side) and the equivalent C# codegen target (Unity side) — or push and let CI regenerate.
  4. 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)

What counts as engine concerns (lives in com.quiz.runtime or per-app code)

Why this boundary

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:

  1. Instantiate the engine-free objects from com.quiz.core (e.g. WebSocketServer, SessionStateMachine, MessageDispatcher).
  2. Subscribe to their events on whatever thread they fire on.
  3. Marshal those events to the Unity main thread (via SynchronizationContext or a queue drained in Update).
  4. Re-emit them as Unity-friendly C# events / UnityEvents for views and game code to consume.
  5. 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 in com.quiz.runtime or 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:

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:

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:

  1. Designer transfer — receiving .quiz package transfers from a Designer.
  2. Client live-play — running live quiz sessions with team Clients.
  3. 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:

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:

  1. Schema — a JSON Schema definition under schemas/object-types/<type-id>/ that codegen produces as a C# type in com.quiz.core for the Unity apps and as a TypeScript interface + ajv validator in the Angular Designer. Each type carries a declared schema version and a JSON serialisation contract.
  2. 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.Preview Unity WebGL build via the in-WebView JS bridge (see Designer Shell).
  3. Host runtime surface — a UGUI prefab + C# behaviour that renders and animates the element on the Host canvas during live play.
  4. Client runtime surface — a UGUI prefab + C# behaviour that renders the element on the Client canvas, including any input handling.
  5. 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:

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

  1. App's bootstrap loads its IObjectTypeRegistry implementation from DI.
  2. Each built-in type is registered (Register(new TextObjectType()), etc.). Registration order is non-significant but must be stable for reproducible test runs.
  3. Once registration is sealed, the registry's Register method becomes a no-op (returns false) — no late registrations.

Package load

  1. Host (or Client) deserialises manifest.json.
  2. For each manifest.objectTypes[] entry, call registry.Negotiate(id, requestedVersion). If any returns Incompatible or UnknownType the package is refused and the user is shown the offending type id.
  3. For each slide, deserialise objectTypeData into the concrete TData for its type via Quiz.Core.JsonContext. The type-specific Validate runs after deserialisation.
  4. Slides are now in memory and addressable.

Slide enter

  1. Runtime adapter instantiates the prefab for each visible element (reveal.trigger == onEntry or visibility == always). Triggered elements are instantiated but kept hidden until their cue fires.
  2. OnSlideEnter is called on each element's runtime in z-order.

Reveal

  1. For trigger == afterDelay: a coroutine in the runtime adapter waits reveal.delayMs and then fires.
  2. For trigger == onCue: the runtime listens on the control message family for a cueReveal envelope keyed by elementId (sent by the quizmaster from the Host operator window or paired Remote).
  3. OnReveal is called; the runtime adapter plays the reveal.animation from DOTween if animation != 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:

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:

  1. Author the type's JSON Schema under schemas/object-types/{type-id}/{type-id}.schema.json and add it to schemas/codegen.config.json.
  2. Run npm run schema:gen (Angular side) and the C# codegen target (Unity side) to produce TData types on both sides.
  3. Implement IObjectType<TData> in com.quiz.core/ObjectTypes/Core/{TypeId}/{TypeId}.cs (handwritten — wraps the generated TData).
  4. Add objectTypeData schema to Slide Schema — Per-Type Schemas — this page is the canonical home for the human-readable form.
  5. Add the type to the built-in catalogue in Object-Type Architecture.
  6. Implement the Designer editor component + DesignerEditor<TData> descriptor in Quiz.Designer/src/app/object-types/{type-id}/.
  7. Implement IHostRuntime<TData> in com.quiz.runtime/Host/{TypeId}/.
  8. Implement IClientRuntime<TData> in com.quiz.runtime/Client/{TypeId}/.
  9. If the type exchanges wire messages, author the protocol message schemas under schemas/live-play/object-types/{type-id}/ and implement IProtocolExtension<TMessage> in com.quiz.core/ObjectTypes/{TypeId}/Protocol/.
  10. Register the type in each app's bootstrap (Unity registries + Angular ObjectTypeRegistry service).
  11. Add round-trip tests for TData (serialisation, validation, migration from prior versions) on both Unity (NUnit) and Designer (Jest) sides, fed by shared fixtures in schemas/fixtures/object-types/{type-id}/.
  12. 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.

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

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:

  1. Package schema versionmanifest.schemaVersion and slide.schemaVersion. Currently 1 for 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 in Quiz.Core.SchemaMigrations).
  2. Object-type schema versions — per-type, declared in manifest.objectTypes[].schemaVersion and stamped per-element in element.typeVersion. Version-negotiation rules live in Object-Type Contract — Version negotiation.

A .quiz is rejected when:

A .quiz is accepted with a warning when:

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

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

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

sequenceDiagram autonumber participant C as Client / Remote participant H as Host C->>H: ws upgrade (path=/live-play or /control) H-->>C: 101 Switching Protocols H-->>C: sessionHello (assigns connectionId, sessionId, sessionElapsedMs origin) C->>H: hello (echoes connectionId, declares clientToken or remoteToken, capabilities) H-->>C: helloAck (assigns peerId; for clients: teamId if recovered, else null) Note over C,H: Live phase — typed message exchange C-xH: socket close / Wi-Fi blip C->>H: ws upgrade with same clientToken H-->>C: sessionHello (same sessionId; reattach path) C->>H: hello (reattach: resumeFromSeq=N) H-->>C: helloAck + sessionSnapshot replay from seq N+1

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 }
} }

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.
eagerPushBegineagerPushChunkeagerPushEnd (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:

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:

  1. The Client opens a fresh WebSocket to /live-play.
  2. The Host issues sessionHello with the same sessionId.
  3. The Client sends hello with clientToken set and resumeFromSeq set to the last known sequence.
  4. The Host issues helloAck with the original teamId, then replays a sessionSnapshot (below) — the current quiz state — followed by any messages with seq > resumeFromSeq.
  5. 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.").

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:

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:

  1. Verify crc32 of chunkBytes. On mismatch: emit chunkAck { seq, status: "crcFail" }; sender retransmits.
  2. Verify seq == expectedSeq. On future seq: queue, emit chunkAck { seq, status: "outOfOrder", expectedSeq }. On past seq (already-acked): emit chunkAck { seq, status: "duplicate" } and drop.
  3. 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):

  1. Designer re-opens the WebSocket and emits transferOffer with the same quizId + packageDigestSha256.
  2. Host looks up its on-disk transfer state for this (quizId, packageDigestSha256) pair. If found and the package digest matches, the Host responds with transferAccept setting resumeFromOffsetBytes to the byte position one past the last successfully-acked chunk and resumeFromSeq to lastAckedSeq + 1.
  3. 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:

  1. Stream integrity — per-chunk CRC32 (handled inline).
  2. Whole-payload integrity — SHA-256 over the assembled bytes, cross-checked against transferOffer.packageDigestSha256.
  3. Manifest validation — once the file is on disk, the Host unzips, parses manifest.json + every slides/*.json, and runs Quiz.Core.SchemaValidator. On any validation error: emit transferComplete { loadedIntoLibrary: false } followed by a transferError { 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).

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):

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

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

Branching + PR policy

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:

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

CI gate

Azure DevOps Pipelines runs the test suites on every PR and every push to main:

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.

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:

  1. set_active_instance to the target editor.
  2. read_console action=clear.
  3. manage_editor action=play.
  4. Wait for the boot chain.
  5. read_console types=["error","warning"].
  6. 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

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

Conventions

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

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:

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:

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:

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

Routing protocol

Run this sequence before every Unity tool call, not just at session start:

  1. Read mcpforunity://instances (resource on the UnityMCP server). Returns a JSON list of live editors with id (Name@hash), name (project_name), path, hash, port, status, last_heartbeat, unity_version.
  2. Map by project_name, never by port. The user names an app (preview / host / client / remote); match it to one of the literal strings Quiz.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.
  3. Call set_active_instance with the matched id token (e.g. Quiz.Host@9f9e4a06). All subsequent Unity tool calls in this session route to that editor until the next set_active_instance.
  4. Re-route on every app switch. If the conversation shifts from Host work to Client work, call set_active_instance again 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

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

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

Host host

Client client

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

Cross-cutting project

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

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.

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

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:


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:

Open:

  1. Recent files (all) dialog — list, search, filter.
  2. Shortcuts dialog — auto-generated from keymap table or hand-written.
  3. About dialog — version, build hash, credits, license content.
  4. Library import resource dialog — drop-target + form vs straight-through OS picker.
  5. Multi-select operations spec — already mocked but matrix entries thin; which bulk ops are supported on N selected slides / elements.
  6. Animation cataloguereveal.animation keys (Beta-phase brand-true motion language).
  7. 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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR SPL[Splash] --> R{Recent file?} R -->|yes| MS[Main shell · loaded] R -->|no| ES[Empty state] ES -->|New / Open / Recent| MS MS --> EXIT[Exit] classDef decision fill:#FFF1DC,stroke:#FFA94D,stroke-width:2px,color:#1F1933 classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class R decision class EXIT terminal

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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR MS[Main shell
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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR D1[Push-to-Host dialog
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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR TR[Toolbar ▸ Run / F5] --> SPAWN[Spawn local Quiz.Host] SPAWN --> RUN[Host running] RUN -->|Esc| MS[Designer main shell] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class MS terminal

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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR OPEN[View menu / statusbar count] --> L[Library panel] L -->|+ Import…| LIP[OS file picker] L -->|click asset| LP[Library picker · binds to property]

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 " QR + URL 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 " reconnecting…" 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:

  1. 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.
  2. Join screen — QR placement, team-roster density, copy when 0 teams joined.
  3. Operator window layout (F-HO-25) — mirror size vs notes prominence vs control density; one-handed vs sit-at-laptop ergonomics.
  4. Single-display fallback overlay — gesture/tap to summon, auto-hide timing, which controls are visible by default vs in a sub-menu.
  5. Incoming-transfer modal — accept/reject affordance, mid-transfer progress, Designer identification copy.
  6. Resume-session dialog — Alpha-phase; copy when the saved-session quiz no longer matches.
  7. Display-config UX — first-launch with multi-display: auto-detect or always ask. Drag-to-arrange screens visual? OS-native picker?
  8. 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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR SPL[Splash] --> CHK{Saved session?} CHK -->|yes| RES[Resume dialog] CHK -->|no| ID[Idle] RES -->|Resume| SE[Session] RES -->|Start fresh| ID ID --> LD[Quiz loaded] LD --> JO[Join screen] JO --> SE SE --> EN[Final standings] classDef decision fill:#FFF1DC,stroke:#FFA94D,stroke-width:2px,color:#1F1933 classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class CHK decision class EN terminal

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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR ID[Idle
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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR JO[Join screen
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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart TB SE[Session
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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR RPP[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:

  1. Discover screen — host list density, refresh affordance, empty-state copy. First impression after install.
  2. 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.
  3. Session HUD — what's always visible vs hidden behind a tap, how the timer renders across phone/tablet form factors.
  4. 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.
  5. Locked-banner copy + animation — F-CL-11.
  6. Standings panel — live during session vs only between rounds vs only on session-end.
  7. Session-code entry (Stretch) — code format display (chunked XX-XXX-XX vs 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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR SPL[Splash] --> DIS[Discover hosts] DIS --> JO[Join] JO --> EP[Eager-push progress] EP --> SE[Session] SE --> END[Final standings] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class END terminal

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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR DIS[Discover
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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR JO[Join screen] --> CUST[Customise
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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR SE[Session
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 — Scan / Enter code 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:

  1. 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.
  2. Pair-method-picker UX — single screen with both Scan + Enter Code on it, or a swipeable tab? Default-focused option?
  3. Rich control affordances — Alpha-phase but worth specifying before code lands so the layout reserves space.
  4. Reveal menu — what reveals are slide-generic vs object-type-specific.
  5. 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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR SPL[Splash] --> SAVED{Saved pairing?} SAVED -->|yes| PAR[Paired · live control] SAVED -->|no| DIS[Discover hosts] DIS --> PM[Pair method picker] PM --> PP[Pair pending] PP -->|accept| PAR classDef decision fill:#FFF1DC,stroke:#FFA94D,stroke-width:2px,color:#1F1933 classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class SAVED decision class PAR terminal

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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart LR DIS[Discover] -->|select host| PM[Pair method picker] PM -->|Scan QR| QR[Camera viewfinder] PM -->|Enter code| CD[Code entry] QR --> PP[Pair pending] CD --> PP PP -->|host accepts| PAR[Paired] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class PAR terminal

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

%%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, system-ui, sans-serif","fontSize":"14px","primaryColor":"#FFFFFF","primaryBorderColor":"#FF009F","primaryTextColor":"#1F1933","secondaryColor":"#F0EBE3","secondaryBorderColor":"#16B2EB","secondaryTextColor":"#1F1933","tertiaryColor":"#F8F5F0","tertiaryBorderColor":"#5A536B","tertiaryTextColor":"#1F1933","lineColor":"#5A536B","edgeLabelBackground":"#F8F5F0","mainBkg":"#F8F5F0","clusterBkg":"#F0EBE3","clusterBorder":"#DDD5C8","titleColor":"#1F1933","nodeBorder":"#5A536B"},"flowchart":{"useMaxWidth":true,"htmlLabels":true,"curve":"basis","padding":20,"nodeSpacing":70,"rankSpacing":80}}}%% flowchart TB PAR[Paired screen
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

Networking and transfer

Schemas and the object-type plugin contract

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

Explicitly not in scope

Acceptance criteria

How success is measured

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:

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

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:

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:

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

Reliability and performance

Documentation

Explicitly not in scope

Acceptance criteria

How success is measured

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

Question-type cohort

Three additions land in Beta, each implementing the full plugin contract:

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:

This includes Quiz UK validation — the mascot, branding, and final brand name are confirmed and applied.

Theme system

Two layers of theming, both Beta:

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

Quiz UK pilot

Explicitly not in scope

Acceptance criteria

How success is measured

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:

Each channel adds work for: signing, packaging, submission flow, and update mechanism.

Identity and policy

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

Explicitly not in scope

Acceptance criteria

How success is measured

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

  1. PRD sign-off. The PRD is the locked-in requirements artefact, generated from the knowledgebase by the docs skill. Sign-off ends "what are we building" debate; subsequent changes go through a controlled change process (re-edit the knowledgebase, regenerate, re-sign-off).
  2. 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.
  3. 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.
  4. 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:

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:

  1. Bundle-supplied object types (Phase-deferred — Stretch). v1 ships built-ins-only; .quiz packages contain no runtime C# code. A future stretch goal — alongside cloud-backed authoring — is to allow object types bundled inside a .quiz package 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.

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

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

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

  5. 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 PlatformAdapter interface, so the port lands a BrowserPlatformAdapter plus the chosen hosting target rather than a frontend rewrite.

  6. Server-backed persistence vendor (Phase-deferred — Stretch). The Designer's PersistenceService / LibraryService / TransferService Angular services abstract over a PlatformAdapter so 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:

Code reuse from the Tauri Designer:

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):

Risks specific to this stretch:

Spikes (when stretch is promoted):

  1. BrowserPlatformAdapter — File System Access API integration; QR / pairing-code Host discovery flow; verify the Angular workspace runs unchanged inside a plain browser tab.
  2. Backend skeleton — API + Postgres + CDN + CI deploy.
  3. Server-backed PersistenceService and LibraryService — replace the Tauri impls with HTTP-backed ones.
  4. Auth + multi-tenant story — OAuth login, per-tenant .quiz storage, isolation enforced.
  5. 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.

Bundle-supplied 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

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:

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:

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.