Live Quiz Platform

Product Requirements Document

Status Draft
Scope Project (cross-cutting)
Last updated 2026-06-14
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 GDScript for the Godot live-play apps (Host, Client, and the Designer's slide preview share one project, Quiz.Stage/) and to TypeScript for the Angular-based Designer and Remote:

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 / first-letter / numeric / eliminator / ranking / buzzer / true-false inputs, timer, leaderboard, image-reveal, match-pairs, word-scramble, 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) and the Quiz Store + Licensing Portal are stretch goals 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) No in v1; stretch Yes — cloud account for cloud-backed authoring + licensing portal credit redemption 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 of the shared Godot project that ships the Host, Client, and slide preview see Repository Layout — Quiz.Stage; 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 Windows, macOS. iPad (with AirPlay), Android tablet, Linux are Stretch.
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 the Quiz.Stage web export embedded inline as a <canvas> inside the Designer's main WebView — the preview is the Host/Client renderer (same project, same scenes, same Compatibility renderer), so pixel fidelity is effectively 100%; the same bundle serves desktop and (Stretch) the future web tool. 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 — Designer language.

The authoring model is PowerPoint-style slides over a question-first domain model — see Question Model. A quiz is an ordered list of slides, optionally grouped into rounds; every slide is either a question or an interstitial. A question slide carries a first-class question node — prompt, answer key, scoring, timing, and the binding to its single input element — and answer keys are Host-only, never shipped to Clients. 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 buzzer 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.Stage preview 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 scoring and timing per question (on the slide's question node — interstitials carry neither) with per-round defaults. 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 platform's Host binary (the Quiz.Stage host export) 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 — a Godot 4.x application, shipped as the host entry scene of the shared Quiz.Stage/ project — runnable on Windows and macOS in v1, typically connected to a projector or TV over HDMI (iPad with AirPlay, Android tablet with Cast, and Linux are Stretch). The Host:

Client client

A lightweight team app for phones and tablets — iPhone, Android phone, iPad, and Android tablet — shipped as the client entry scene of the shared Godot 4.x Quiz.Stage/ project, so it renders slides with exactly the same scenes as the Host. 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 is a Tauri 2 + Angular app shipped to iPhone, Android phone, iPad, and Android tablet via Tauri mobile, reusing the Designer's design tokens, components, WebSocket client, and a11y infrastructure (shared Angular code is extracted into a pnpm workspace package when the Remote scaffolds — see Tech Stack). The Remote is pure operator UI with no slide renderer of its own. 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 — Remote language 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 the Godot 4.x live-play surfaces — Host, Client, and the Designer's slide preview, all three apps inside the single Quiz.Stage/ project (Repository Layout) — with a Tauri 2 + Angular 21 Designer for content authoring and a Tauri 2 + Angular Remote as the quizmaster's controller (iOS / Android via Tauri mobile). The preview is the Quiz.Stage web export, 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 and Client render with Godot Control / Node2D scenes for creative freedom (custom shaders, particles, animations); the Remote is pure operator UI whose host-canvas mirror is a downscaled frame stream pushed by the Host — it has no slide renderer of its own.

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
Godot · Quiz.Stage · TV / projector app] REM[Remote
Tauri 2 + Angular · quizmaster controller] CLI[Clients × N
Godot · Quiz.Stage · 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-06-12

Tech Stack

Component Choice Rationale
Host, Client, slide preview Godot 4.x (GDScript) — one shared project, Quiz.Stage/, three entry scenes + per-app export presets Single engine and a single project across every surface that renders a slide. Text-based scenes (.tscn) and scripts diff cleanly and are first-class for AI-driven development; headless CLI (godot --headless) gives honest CI without licensing; exports are ~40 MB vs Unity's ~150 MB+. One project means the slide/element renderer is shared by construction — no package machinery, no cross-project asset-id drift. See Repository Layout and Decisions.
Stage rendering pipeline Compatibility (GL) renderer on every preset The web export only supports the Compatibility renderer. Running the same renderer on desktop Host, mobile Client, and web Preview makes the Designer's embedded preview pixel-faithful to the live apps — same engine, same renderer, same scenes.
Remote Tauri 2 + Angular (iOS / Android via Tauri mobile) The Remote is pure operator UI — Studio chrome, host-notes pane, nav controls, session HUD. DOM is the right tool; it reuses the Designer's design tokens, components, WebSocket client, and a11y infrastructure. Shared Angular code is extracted into a pnpm workspace package when the Remote scaffolds. The host-canvas mirror is a downscaled frame stream from the Host (see Networking) — the Remote never re-implements slide rendering.
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 Godot web export of Quiz.Stage embedded as a <canvas> inside the Designer's main WebView The preview is the Host/Client renderer — same project, same scenes, web export preset. Pixel fidelity is effectively 100% because every preset runs the Compatibility renderer. Single-threaded web export avoids the COOP/COEP (SharedArrayBuffer) header requirement inside the WebView. Angular pushes slide state via the export's JavaScriptBridge window API; the preview emits ready / rendered / error callbacks back to Angular services. The same bundle slots into the future Web Designer without rework.
Designer ↔ Preview transport In-WebView JS bridge (Godot JavaScriptBridge ↔ window-level JS API) The preview runs inside the Designer's main WebView; no inter-process IPC.
Run from slide subprocess launch Tauri sidecar Command::spawn Designer spawns the platform 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) Godot Control nodes + Node2D scenes Godot's Control anchors/containers map 1:1 onto the element model's anchors + pivot (see Object-Type Contract). Shaders (Godot shading language), GPUParticles2D, AnimationPlayer, and built-in Tween cover the "big reveal" production values.
Shared logic (Stage apps) Engine-free GDScript under Quiz.Stage/core/ — plain RefCounted classes, no scene/Node dependencies Quiz schema types (generated), packaging, scoring rules, session state machine, protocol dispatch shared between Host, Client, and Preview by living in the same project. Headless-testable with GdUnit4 — no display, no scene tree. See Separation of Concerns.
Designer ↔ Stage contract Schema-first — JSON Schema source of truth in schemas/ Codegen produces GDScript types + validators for Quiz.Stage and TypeScript types + validators for the Angular Designer/Remote. Designer business logic is implemented natively in TypeScript inside Angular services; no shared runtime library crosses the language boundary. 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.
.quiz archive I/O (Stage apps) Godot built-in ZIPReader / ZIPPacker Zero dependencies; reading the package and validating the manifest is engine-free GDScript in core/.
Embedded WebSocket server (Host) + client (Client) Godot built-insTCPServer + WebSocketPeer (server-side handshake) on the Host; WebSocketPeer client on the Client No third-party networking dependency. The server loop runs in _process on a dedicated autoload; protocol interpretation stays in engine-free core/ classes.
WebSocket client (Designer / Remote) Browser WebSocket API (via rxjs/webSocket) Both run 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. The Remote-control family additionally carries the downscaled Host-canvas frame stream for the Remote's mirror. See Networking.
Session state persistence (Host) Local file (JSON, periodic snapshot) in user:// Crash recovery is an Alpha-phase v1 requirement. Snapshot after every scoring event and every slide advance — small writes. Format is the same DTOs as the live message envelopes, so deserialise == replay.
Service discovery (Stage apps) Pure-GDScript mDNS over PacketPeerUDP multicast (Host advertise; Client browse) No built-in mDNS in Godot; the protocol is small and the implementation lives in engine-free core/discovery/. iOS caveat: raw multicast on iOS requires the restricted com.apple.developer.networking.multicast entitlement; the no-approval alternative is a thin native Bonjour (NWBrowser) plugin. Decision deferred to the Client chunk — see Open Questions. QR-code / manual-IP join is the universal fallback.
Service discovery (Designer / Remote) mdns-sd Rust crate via a Tauri command The Rust shell handles mDNS browse; Angular calls Tauri commands and consumes the results. Same iOS-entitlement caveat applies to the Remote's mobile build.
Camera capture (Client) Godot CameraServer with a native-plugin fallback Team-photo capture at join (F-CL-14): capture frame → centre-crop → bilinear downscale to 256 × 256 → JPEG-encode (q75, 96 KB cap). CameraServer maturity on Android is unproven — prototype early in the Client chunk; fall back to a small native plugin if needed. See Open Questions.
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 (Stage) Godot nativeAnimationPlayer, Tween, GPUParticles2D, Godot shading language First-class tooling for snappy animations, custom shaders, and "big reveal" moments. Built-in Tween covers sequences, easings, and chaining — no third-party tween library.
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 Godot native Same engine as the rest of the stage; no embed required.
3D content Godot 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 the Quiz.Stage web export — the same Godot project that ships the Host and Client, exported with the preview-web preset — embedded inline as a <canvas> in the Tauri WebView.

The project layout (Quiz.Designer/ Tauri + Angular workspace, schemas/ for the cross-language contract, Quiz.Stage/ Godot 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.Stage 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 · window-level JS API ↔ Godot JavaScriptBridge] PREV[Quiz.Stage <canvas>
Godot web export — preview-web preset — embedded inline] end subgraph t3["Tier 3 · Contracts + Stage foundation"] direction LR SCH[schemas/
JSON Schema source of truth
codegen TS for Angular · codegen GDScript for Quiz.Stage] STAGE[Quiz.Stage
one Godot project · core/ engine-free GDScript · render/ shared slide renderer
ships Host / Client / Preview from one tree] 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 · state push + render events| PREV SVC -.->|TS types from| SCH SCH -.->|GDScript types into| STAGE PREV -->|is the preview-web export of| STAGE 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,STAGE 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 + Stage foundation

Designer business logic is not part of Quiz.Stage/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.

Preview fidelity

The preview is the Host/Client renderer: the same project, the same render/ scenes, and — because every Stage export preset runs the Compatibility (GL) renderer — the same rendering pipeline. Fidelity is effectively 100%.

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.Stage web export 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; the generated ajv 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 the embedded Quiz.Stage preview over the JS bridge. The preview renders the slide pixel-faithful — the same scenes the Host will instantiate at quiz time.
  4. User edits a property. Angular updates AuthoringSession via a command (undoable), pushes the delta to the preview. The canvas 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; Host, Client, and the Designer's slide preview share one Godot project; the Designer and Remote consume 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/, lib/, generated/
│   ├── public/                 #   Static assets bundled into the WebView
│   ├── e2e/                    #   Playwright specs driven against ng serve
│   ├── angular.json
│   ├── package.json
│   └── tsconfig.json
├── Quiz.Stage/                 # ONE Godot 4.x project — Host + Client + Preview (see below)
├── Quiz.Remote/                # Tauri 2 + Angular — quizmaster controller (iOS / Android); scaffolds at its build-plan chunk
├── schemas/                    # JSON Schema source of truth — codegen targets GDScript + TypeScript
│   ├── package-format/         #   .quiz manifest, slides, resources
│   ├── live-play/              #   WebSocket message envelopes
│   ├── transfer/               #   Designer → Host transfer framing
│   ├── messaging/              #   Designer cross-window bus topics
│   ├── object-types/           #   Per-type component payloads
│   ├── fixtures/               #   Shared validation fixtures asserted by every consumer
│   └── codegen.config.json     #   codegen configuration (TS + GDScript outputs)
├── 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.Remote/ is a second, much smaller Tauri 2 + Angular workspace targeting Tauri mobile (iOS / Android). Angular code shared with the Designer (design tokens, chrome components, WebSocket client, command-error envelope handling) is extracted into a pnpm workspace package under the repo root when the Remote scaffolds — until then it lives in Quiz.Designer/src/lib/.

Quiz.Stage/ — one Godot project, three apps

Host, Client, and the Designer's embedded slide preview are one Godot project with three entry scenes and per-app export presets. The slide/element renderer is shared by construction — there is no shared-package machinery and no cross-project asset-id drift (sharing scenes between Godot projects fights the engine's per-project .uid registry; sharing them inside one project is free).

Quiz.Stage/
├── project.godot               # Project config — Compatibility renderer on every preset
├── export_presets.cfg          # host-windows, host-macos, client-android, client-ios, preview-web
├── boot/
│   └── boot.gd                 # Entry dispatch: export feature tag (host/client/preview) or --app= override
├── apps/
│   ├── host/                   # host_main.tscn + Host-only scenes/scripts (server, operator window, HUD)
│   ├── client/                 # client_main.tscn + Client-only scenes/scripts (join flow, input surfaces)
│   └── preview/                # preview_main.tscn — render target driven by the Designer's JS bridge
├── core/                       # ENGINE-FREE GDScript — plain RefCounted classes, no Node/scene deps
│   ├── generated/              #   GDScript codegen output from schemas/ (git-ignored)
│   ├── packaging/              #   .quiz read/write (ZIPReader/ZIPPacker), manifest validation
│   ├── protocol/               #   message envelopes, dispatch, session state machine
│   ├── scoring/                #   scoring rules, tiebreakers
│   ├── discovery/              #   mDNS advertise/browse over PacketPeerUDP
│   └── object_types/           #   ObjectType contract + registry (see object-type-contract.md)
├── render/                     # Shared slide/element renderer — object-type scenes used by all three apps
├── assets/                     # Brand fonts, palette resources, mascot, audio stings
└── tests/                      # GdUnit4 — unit tests for core/, scene tests for render/ + apps/

Cross-language contract — schemas/

schemas/ is the source of truth for every data shape that crosses the Designer/Remote ↔ Stage boundary. Files are written as JSON Schema (draft 2020-12). Codegen runs before each side builds:

A single schemas/codegen.config.json declares the inputs, outputs, and naming conventions.

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 GDScript inside Quiz.Stage/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.

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/lib.rs              #   shell bootstrap, command registrations
│   ├── src/commands/           #   thin command layer exposed to the Angular side
│   ├── src/services/           #   Tauri-runtime-free policy/logic behind trait seams
│   ├── tests/                  #   IPC contract tests
│   ├── Cargo.toml
│   └── tauri.conf.json         #   window decorations, plugins, capabilities, CSP
├── src/                        # Angular 21 application
│   ├── app/                    #   feature areas — authoring/, canvas/, panels/, inspectors/, …
│   │   └── generated/          #   codegen'd TS types + ajv validators (git-ignored)
│   ├── lib/                    #   shared seams — windows/, panels/, color/, messaging/
│   └── styles.scss             #   Studio chrome tokens, design-spec CSS
├── e2e/                        # Playwright specs — driven against ng serve
├── angular.json
├── package.json
└── tsconfig.json
Folder Purpose
src-tauri/ Rust shell. Thin commands + tested services. Not where business logic lives.
src/app/ Angular standalone components + services — every authoring surface. Business-logic ground truth on the Designer side.
src/lib/ Shared, app-agnostic seams (window framework, panel layout tree, colour math, typed message bus). First candidates for extraction into the shared pnpm package when the Remote scaffolds.
src/app/generated/ Codegen'd TypeScript types + ajv-compiled validators derived from schemas/. Regenerated on pnpm schema:gen.
e2e/ Playwright specs that drive ng serve for visual / interaction verification.

The Designer's embedded preview loads the Quiz.Stage web export from src/assets/preview/ (CI artefact, git-ignored), inline as a <canvas> via the Godot web loader.

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 TypeScript and GDScript.
  3. Run pnpm schema:gen (Angular side) and the GDScript generator (python schemas/codegen/gen_gdscript.py) — 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.

Adding a new Stage app surface

A new audience/player-facing surface (a fifth app, a kiosk mode) is a new entry scene inside Quiz.Stage/, not a new project:

  1. Create apps/<name>/<name>_main.tscn + scripts.
  2. Add an export preset with the <name> feature tag and per-app identity.
  3. Extend boot.gd's dispatch table.

It shares core/ and render/ automatically.

Separation of Concerns: Business Logic vs Engine Logic

A guiding architectural principle, applied across the Stage apps (Host, Client, Preview — one Godot project, Quiz.Stage/): business logic is engine-free GDScript and lives in Quiz.Stage/core/; engine concerns live in scenes under apps/ and render/. The boundary is enforced by a GdUnit4 conformance test that scans every script under core/ and fails on any reference to Node, SceneTree, CanvasItem, scene-tree access, or an autoload — core/ cannot silently grow a scene dependency.

"Engine-free" means no scene tree and no display, not "no Godot API". core/ scripts are plain RefCounted classes and may freely use Godot's headless runtime classes — WebSocketPeer, TCPServer, PacketPeerUDP, ZIPReader/ZIPPacker, JSON, Crypto — all of which run under godot --headless with no scene instantiated. What core/ may never touch is anything that needs a frame, a viewport, or a node lifecycle.

The Designer and the Remote are a separate stack (Tauri + Angular, TypeScript) and do not consume core/. Their 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 Stage 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 Quiz.Stage/core/)

What counts as engine concerns (lives in apps/ or render/)

Why this boundary

How the scene layer adapts engine-free code

The scene layer is intentionally a thin adapter, not a replication of business logic. The shape is consistently:

  1. Instantiate the engine-free objects from core/ (e.g. WebSocketServer, SessionStateMachine, MessageDispatcher) — typically owned by the networking autoload or the app's main scene.
  2. Pump them — call poll() / tick() once per _process frame where the object needs it.
  3. Connect to the signals they emit (team_joined, slide_advanced, message_received). Everything fires on the main loop; no marshalling layer exists or is needed.
  4. On the way back in (player input, view actions), call the engine-free objects' methods directly — same loop, same frame.

If a method in an apps/ or render/ script is doing more than instantiate / pump / connect / forward — if it's deciding something about scoring, protocol state, or session validity — that decision belongs back in 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 core/. If it would be meaningless without a screen, an audio device, or an input device, it's an engine concern — put it in apps/ or render/.

The socket layer answers "yes" to that question — sockets don't need a display. So sockets live in core/, and only the once-per-frame pump lives in the scene layer.

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 (an object_types/ folder shipping scripts + scenes 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 the schema validators generated from schemas/ — GDScript in Quiz.Stage/core/generated/ on the Stage side, ajv in the Designer — 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 generated types on both sides 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 — Godot built-ins (TCPServer + WebSocketPeer server handshake), pumped once per _process frame by a dedicated autoload; protocol interpretation stays in engine-free core/ classes (see Separation of Concerns). It advertises itself on the local network via Bonjour/mDNS — a pure-GDScript implementation over PacketPeerUDP multicast in Quiz.Stage/core/discovery/ (iOS multicast caveat + the QR / manual-IP fallback: see Tech Stack and Open Questions). 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, buzzer adjudication); 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 Quiz.Stage/core/generated/ (GDScript) and the Designer / Remote Angular workspaces (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 (Godot WebSocketPeer client, polled from the same main-loop pump), and exchange typed messages defined in the shared schema under schemas/live-play/ (codegen'd to GDScript inside Quiz.Stage/core/generated/). 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. The push carries the stripped package — at package-load the Host removes every question.answerKey into a session-side answer table, so Clients never receive answer keys (custody decision: Question Model; wire framing: Live Play Protocol). 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 question is configured (by the author) to accept late submissions, in which case the Host applies the configured question.timing.lateSubmission policy (accept-full / accept-penalty; see Question Model). 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 — a downscaled frame stream pushed by the Host (the Remote has no slide renderer of its own; see Decisions). 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. Adjudicate buzzer answers (Correct / Incorrect — see Live Play Protocol). 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. Remove a team. adjudicate generalises to the marking queue.

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

Related: Question Model — slide kinds + first-class questions. Where this page and the Question Model disagree, the Question Model wins.

The atomic unit a quiz is built from is the slide. Per the Question Model, every slide is either a question or an interstitial. A slide owns two canvases (Host and Client) and an ordered list of elements placed on each; elements are instances of object types. On a question slide the question itself is a first-class domain object — slide.question carries the prompt, answer key, scoring, and timing — and exactly one input element on the client canvas renders it, bound via question.inputBinding.

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 typed GDScript class in Quiz.Stage/core/generated/ for the Stage 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-faithful render comes from the Host runtime surface (#3) running in the embedded Quiz.Stage web export via the in-WebView JS bridge (see Designer Shell).
  3. Host runtime surface — a scene under Quiz.Stage/render/<type-id>/ whose root script implements the host_runtime contract; renders and animates the element on the Host canvas during live play.
  4. Client runtime surface — a scene under Quiz.Stage/render/<type-id>/ whose root script implements the client_runtime contract; 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.

Question types layer a second contract on top of these element-level surfaces — an answer-key schema, a submission payload schema, a built-in answer-reveal presentation, and a Designer slide template. The precise shape is pinned in Object-Type Contract — Question-type contract layer.

Each object type has a globally unique, namespaced type id (e.g. core.text, core.image, core.multiple-choice, 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 shipped inside 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 catalogue

The catalogue splits along the Question Model's fault line:

Phase columns show when each lands — see Phases. The v1 catalogue spans MVP, Alpha, and Beta; a small Stretch backlog tracks formats that didn't make the v1 cut and earn their own type rather than being expressed as configuration on existing types.

Question types

All 8 ship in MVP (the earlier 4-vs-8 scope conflict is resolved in the Question Model). Canonical answer-key shapes and auto-marking rules live in the Question Model — catalogue table; the per-type mechanics pinned there are one-lined below, not duplicated.

Answer keys live on slide.question.answerKey — never in the element's objectTypeData. The input element keeps presentation config only (option labels + layout + shuffle, keypad style, button arrangement) plus option identity (optionId) so submissions can reference options; option correctness lives in the answer key, which is Host-only custody and stripped from the live-play push to Clients (see Question Model — answer-key custody).

questionType (= input element type id) Phase Question + input Notes
core.multiple-choice MVP Tap one option from an authored set. Beta extends the answer key with a multi-answer mode (multiple correct options must all be selected, partial-credit configurable). Speed-bonus curves are question.scoring.curve values, not element config; elimination timelines belong to core.eliminator — there is no element-level copy of either.
core.free-text MVP Text-entry answer field. answerKey: { acceptedAnswers, matchMode }. Auto-marked; Alpha adds the marking-queue review pass for borderline submissions.
core.first-letter MVP A–Z grid tap — team picks the leading letter of the answer. Single-pick, 3-row layout default, inspector may constrain to a subset; auto-marked against correctLetterpinned mechanics. Pairs with core.audio-clip (Alpha) to deliver "music question" rounds without a dedicated music type.
core.numeric MVP Numeric entry. answerKey.proximity: exact | closestAbsolute | closestRelative — one type covers exact-match and closest-wins (essential for estimation rounds and tiebreakers).
core.true-false MVP Binary ✓ / ✗ — two large tap targets, visually distinct from multiple-choice. answerKey: { correctAnswer }.
core.ranking MVP "Sort it out" — drag-to-reorder. Items are text, image, or both. answerKey.credit: allOrNothing | perPosition.
core.eliminator MVP Options vanish on an authored timeline or quizmaster cue; teams answer any time — earlier scores more via the speed bonus; final reveal is quizmaster-cued — pinned mechanics. answerKey: { correctOptionId, eliminationSchedule }. Host canvas renders the drop animation; client canvas picks from the remaining options. Separate from core.multiple-choice so the host-driven final-reveal beat is first-class.
core.buzzer MVP First-press race: the first press locks all buzzers, fires that team's buzzer jingle (F-HO-27), and the quizmaster adjudicates the verbal answer via Correct / Incorrect operator buttons — pinned mechanics. No answer key; the MVP's only manually-marked type. The press is final — no resubmission. The element owns only the press-race protocol + on-canvas visual; jingle resolution happens in the Host's team-identity registry (F-CL-15, F-DE-28).

Later question types — when these land they land as question types (a questionType + bound input element + answer-key schema), conforming to the same contract layer:

questionType Phase Question + input
core.image-reveal Alpha Image obscured by a configurable shader filter (pixelate / blur / mosaic / warp — author-picked) that gradually clears over the question duration; multiple-choice submission underneath. Earlier-correct = more points via question.scoring.curve (linear / ease-out / stepped) — no element-level points function. Inspector configures filter type and start/end intensity (presentation); the answer key is MCQ-shaped. Host canvas (image) + client canvas (answer input).
core.match-pairs 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 (answer key carries the pair map).
core.word-scramble 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 scramble difficulty (animation intensity + hint cadence); the answer key carries the target word, wrong-answer penalty, and per-team buzzer cooldown after a wrong attempt.
core.categories Stretch Multi-line free-text. "Name 5 X." Each line scored independently against the answer key.
core.drawing Stretch Finger drawing capture (touch-only); can mirror to Host. Moved out of v1 — touch-drawing input is not a defining live-quiz format on its own. Quizmaster-adjudicated.
core.hotspot Stretch Tap on image coordinates. "Where on the map is X?" / anatomy / geography. Answer key carries the target region.

Content / display elements

No question semantics — pure presentation, placeable per the capability rules below.

Type id Phase Purpose Typical placement
core.text MVP Rendered text block. On question slides the per-type template binds one text element per canvas to question.prompt by default (override allowed) — the prompt's canonical home is the question node, not the element. Either canvas, either slide kind.
core.timer MVP Countdown / elapsed display — a view of question.timing, carrying no duration of its own. Lock-on-time-up, quizmaster override, and late-submission policy all live on question.timing (see Question Model — scoring + timing); the element only renders the authoritative Host clock (Networking). Valid only on question slides whose question.timing.durationMs > 0 — the validator warns otherwise; interstitials cannot host timers. Either canvas of a timed question slide.
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). Host canvas by default; the Client canvas only renders the placed leaderboard when the per-quiz clientLeaderboardVisible toggle is true. Distinct from the Alpha on-demand scoreboard overlay. Either canvas, either slide kind.
core.image Alpha Static image from the package's resources/images/. Either canvas.
core.audio-clip Alpha Playable audio clip from resources/audio/. Inspector exposes a playTrigger (on-entry / on-cue / after-delay) so the clip can drive a music-question beat. Host canvas.
core.video Alpha Video clip from resources/video/. Host canvas.
core.mini-game Beta Embeds a mini-game. The mini-game framework itself is a separate build-plan deliverable; how mini-game slides map onto the question / interstitial dichotomy is settled when that framework lands. Client canvas (typically).

Per-question and quiz-wide game modes that are not object types:

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. Six 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 "Duel." 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 (the slide's question resolution provides the trigger), allow-zero-segments flag.
mini-game.countdown TV-show "Countdown" letter round. Letters drawn from a configured consonant/vowel mix appear on the Host canvas; teams race to build the longest valid word from those letters within the window. Scoring weighted by word length. Letter mix (consonant/vowel ratio), letter count (default 9), round duration (default 30s), dictionary source, points-per-letter curve, allow-proper-nouns flag.
mini-game.wheel-of-fortune Phrase hidden behind tile mask on the Host. Teams guess letters (turn-based round-robin or buzzer-race — author picks); correct guesses reveal every matching tile. First team to solve the phrase scores. Target phrase, turn rule (round-robin / buzzer-race), points for letter-hit, points for solving, allowed-mistakes-per-team, hint cadence.
mini-game.wordsearch Word-search grid rendered on the Host and the Client (each team has the same grid). Teams race to find target words. Grid size, word list, scoring mode (first-to-find each word / time-based per-word / find-as-many-as-possible-in-T), round duration, points-per-word.

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 seventh 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. (The eliminator's option-drop timeline is not a reveal trigger — it lives in answerKey.eliminationSchedule, per the Question Model.)

Shared element properties

Every placed element — regardless of object type — carries the same shared property block in addition to its object-type-specific component data. 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, question.inputBinding, 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). Bound at insertion; not re-editable. For an input element this matches the slide's question.questionType — validator-enforced.
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.
components ComponentInstance[] Ordered typed-component list — index 0 is always core.rect-transform (anchors + pivot + position + size + rotation; the same responsive model on both canvases — the old region/stack system is deleted per the Question Model). The primary-type component matches type and carries the per-type presentation payload. Render order across a canvas is element-array order — there is no zOrder field; bring-forward / send-back are array moves. Invariants in Object-Type Contract.
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 via the primary-type component. The full field-level schema for the shared block and every per-type payload lives in Slide Schema (resolves Open Questions #2). The right-pane inspector renders the shared block in a fixed order (Identity → RectTransform → 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

Related: Question Model — slide kinds + first-class questions. Where this page and the Question Model disagree, the Question Model wins.

The contract 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. Question types carry an additional contract layer on top of the element-level surfaces — see Question-type contract layer.

Elements own a list of typed components

Every placed element on a slide canvas carries an ordered components: ComponentInstance[] list. Two invariants hold:

  1. Index 0 is always a core.rect-transform component carrying position, size, rotation, anchors and pivot. The Designer's withRectTransform helper enforces this on every mutation; loaders coerce missing or out-of-order rect-transforms back to index 0.
  2. A primary-type component matches element.type and owns the bespoke per-type payload. The Designer's palette / inspector / thumbnail keyed look-ups all route through element.type so the discriminator stays meaningful — type is the "recipe" id, the component bag is the realised instance.
interface ComponentInstance {
  componentType: string;   // "core.rect-transform" | "core.text" | "core.image" | …
  data: unknown;           // shape owned by the named component type
}

Per-component data shapes are owned by their registered type, not by the element envelope. The Designer's element-components.ts helper module is the single seam consumers reach for — getRectTransform(el), getTypeData<T>(el), withRectTransform(el, patch), replaceTypeData(el, data). Code reading element.components[i] directly is reviewer-rejected.

The model is engine-neutral: anchors + pivot are authored even when the Designer renders at a fixed canvas size, so the Host / Client runtime can resolve them at any actual viewport. Godot's Control anchor system maps naturally onto the authored anchors + pivot — the render/ scenes resolve them directly onto Control anchor/offset properties. v0 stores absolute x/y/width/height alongside anchors + pivot; future runtime work derives positioning from anchors at resize time.

The properties inspector renders one collapsible section per component: RectTransform first (always present), then the primary-type editor, with optional non-primary components stacked beneath.

The contract lives in Quiz.Stage/core/object_types/ (engine-free data layer — contract classes, registry, validation, version negotiation; per-type data classes regenerated from schemas/ into core/generated/) and Quiz.Stage/render/ (engine layer — per-type scenes whose root scripts implement the runtime contracts). 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 GDScript data class consumed by the Stage apps.

Surface contributions

Every type contributes the five element-level surfaces enumerated in Object-Type Architecture — schema, Designer editor, Host runtime, Client runtime, optional protocol extension. The contracts below pin each one. Question types contribute four further question-level surfaces, pinned next.

Question-type contract layer

The eight MVP question types (core.multiple-choice, core.free-text, core.first-letter, core.numeric, core.true-false, core.ranking, core.eliminator, core.buzzer — and every later question type) are ordinary object types whose element renders the bound input on the client canvas. On top of the element-level surfaces, a question type declares:

  1. Answer-key schema — the type-specific shape of slide.question.answerKey (e.g. { correctOptionId } for multiple-choice; canonical shapes in the Question Model catalogue). Consumed Host-side only by the marker; stripped from the eager push to Clients per answer-key custody. The element's own objectTypeData carries presentation config + option identity only — never correctness.
  2. Submission payload schema — the body of the answerSubmit message the client input element emits, riding the type's ProtocolExtension on the shared envelope. slideId is the question id on the wire; the Host resolves the element via question.inputBinding (Live Play Protocol).
  3. Answer-reveal presentation — the built-in reveal the Host renders on the audience window when the quizmaster cues Reveal answer (e.g. MCQ highlights the correct option, free-text shows the canonical accepted answer). Driven entirely from answerKey via the answerReveal message — authors never hand-place duplicate answer text. Custom author-styled reveals are Beta.
  4. Designer slide template — the scaffold the "+ Add slide" type picker instantiates: prompt text elements on both canvases (template-bound to question.prompt), the input element pre-placed on the client canvas and bound via question.inputBinding, and default question.scoring / question.timing. The freeform canvas tools then decorate the scaffold (Designer Flows).

Marking itself (auto-markers for seven types, buzzer adjudication, the Alpha marking queue) is Host session logic keyed off the answer-key schema — it is not an element surface.

ObjectType — engine-free base class

The root contract. One subclass per object-type id, registered at app startup into the per-app registry. The per-type data is a generated GDScript class (from schemas/, in core/generated/) that round-trips with element.objectTypeData per Slide Schema; every generated data class carries schema_version: int.

# Quiz.Stage/core/object_types/object_type.gd
class_name ObjectType
extends RefCounted

var id: String                              # e.g. "core.text"
var schema_version: int                     # current built-in version
var display_name: String                    # shown in palette
var description: String                     # shown in palette tooltip
var capabilities: ObjectTypeCapabilities

func default_data() -> ObjectTypeData:                                    # generated base class
    assert(false, "subclass must override"); return null

func validate(data: ObjectTypeData, ctx: ValidationContext) -> ValidationResult:
    assert(false, "subclass must override"); return null

func migrate(data: ObjectTypeData, from_version: int) -> ObjectTypeData:
    assert(false, "subclass must override"); return null
# Quiz.Stage/core/object_types/object_type_capabilities.gd
class_name ObjectTypeCapabilities
extends RefCounted

var allowed_on_host_canvas: bool
var allowed_on_client_canvas: bool
var accepts_input: bool             # true => Client-side input
var emits_protocol_messages: bool   # true => has a ProtocolExtension
var is_stateful: bool               # true => participates in crash-recovery snapshot

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 (shipped shape)
import type { Type } from '@angular/core';
import type { Element } from '../generated';

/** Palette tile pictogram — a text glyph rendered as-is, or a 24×24
 *  SVG path (single `d` string, filled with `currentColor`). */
export type PaletteIcon = { readonly glyph: string } | { readonly svgPath: string };

export interface DesignerEditor<TData = unknown> {
  readonly typeId: string;                            // matches ObjectType.id, e.g. "core.text"
  readonly schemaVersion: number;                     // mirrors the type's built-in schema version
  readonly paletteIcon: PaletteIcon;
  readonly paletteGroup: string;                      // palette section heading — data-driven, not an enum
  readonly displayName: string;
  readonly inspectorComponent: Type<unknown> | null;  // standalone Angular inspector panel
  /** Fresh primary-type component payload, used when the author drags
   *  the type onto a canvas (wrapped into a typed component instance). */
  newDefaultData(): TData;
  /** Optional slide-rail thumbnail renderer — synchronous + cheap; draws
   *  into the supplied 2D context at orchestrator-computed coordinates.
   *  Omitted ⇒ the renderer falls back to a toned rectangle. */
  renderThumb?(ctx: CanvasRenderingContext2D, element: Element, opts: ThumbRenderOpts): void;
}

paletteGroup is a free string — grouping is data-driven, so adding a group never touches the palette component. Groups currently in use: "Text" (core.text) and "Media" (core.image). ThumbRenderOpts (same file) carries the element's pre-scaled bounding box, a library accessor for asset-backed types, an onReady repaint callback for async asset loads, and the device pixel ratio.

The Angular inspector 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 GDScript data class on the Stage 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 GDScript and TS validators agree on every test input — see Repository Layout — Cross-language contract.

host_runtime — Host surface

Engine layer. A scene under render/<type-id>/ whose root script implements the duck-typed host_runtime contract — GDScript has no interface keyword; the registry validates the contract at startup via has_method() checks (see Lifecycle). Lifecycle hooks are invoked by the Host's slide runner.

# render/<type-id>/<type_id>_host.tscn — root script implements:

func on_slide_enter(ctx: HostElementContext, data: ObjectTypeData) -> void
func on_reveal(ctx: HostElementContext, data: ObjectTypeData) -> void       # called when reveal.trigger fires
func on_advance(ctx: HostElementContext, data: ObjectTypeData) -> void      # called on slide leave
func on_tick(ctx: HostElementContext, data: ObjectTypeData, tick: TickInfo) -> void
func on_protocol_message(ctx: HostElementContext, data: ObjectTypeData, msg: ObjectMessage) -> void
# Quiz.Stage/core/object_types/host_element_context.gd
class_name HostElementContext
extends RefCounted

var element: Dictionary        # the element record (shared block + components)
var slide: Dictionary
var session: SessionState
var messages: HostMessageSink  # outbound protocol messages
var audio: AudioBus            # shared audio bus

The scene path is registered alongside the type (see Registry); the slide runner load()s the PackedScene once per slide load, instantiates it under the Host canvas root, and binds its root script. on_tick fires at the Host's slide-tick cadence (≥ 60 Hz).

client_runtime — Client surface

Mirror of host_runtime with Client-specific concerns (input handling, lock state, anchor-resolved layout — both canvases share the core.rect-transform model; the old region/stack system is deleted per the Question Model). Adds on_lock / on_unlock; drops on_tick.

# render/<type-id>/<type_id>_client.tscn — root script implements:

func on_slide_enter(ctx: ClientElementContext, data: ObjectTypeData) -> void
func on_reveal(ctx: ClientElementContext, data: ObjectTypeData) -> void
func on_lock(ctx: ClientElementContext, data: ObjectTypeData) -> void
func on_unlock(ctx: ClientElementContext, data: ObjectTypeData) -> void
func on_advance(ctx: ClientElementContext, data: ObjectTypeData) -> void
func on_protocol_message(ctx: ClientElementContext, data: ObjectTypeData, msg: ObjectMessage) -> void
# Quiz.Stage/core/object_types/client_element_context.gd
class_name ClientElementContext
extends RefCounted

var element: Dictionary
var session: SessionState
var messages: ClientMessageSink
var is_locked: bool

on_lock / on_unlock correspond to the Host's authoritative timer lock — the Client must stop accepting input on on_lock and may resume on on_unlock.

ProtocolExtension — optional protocol extension

Object types that exchange wire messages (e.g. an answer-submit, a buzzer-press, a drawing-stroke) register a protocol extension — a serialize / deserialize pair keyed by message namespace. The Host and Client carry the message via the shared envelope (see Live Play Protocol — Object-message extensions).

# Quiz.Stage/core/object_types/protocol_extension.gd
class_name ProtocolExtension
extends RefCounted

var object_type_id: String       # matches ObjectType.id
var message_namespace: String    # e.g. "core.mcq" — used in envelope routing
var schema_version: int          # mirrored from ObjectType.schema_version

func deserialize(body: Dictionary) -> ObjectMessage:
    assert(false, "subclass must override"); return null

func serialize(msg: ObjectMessage) -> Dictionary:
    assert(false, "subclass must override"); return {}
# Quiz.Stage/core/object_types/object_message.gd
class_name ObjectMessage
extends RefCounted

var kind: String     # e.g. "submit" | "press" | "stroke"

Multiple message kinds can share one extension — kind discriminates inside the envelope's payload.

ObjectTypeRegistry

The per-app registry. One instance per app, populated at startup. Engine-free (core/object_types/) — runtime scenes are registered as path strings, so the registry never holds a PackedScene reference.

# Quiz.Stage/core/object_types/object_type_registry.gd
class_name ObjectTypeRegistry
extends RefCounted

enum NegotiationResult { OK, UPGRADED_ON_LOAD, INCOMPATIBLE, UNKNOWN_TYPE }

func get_type(id: String) -> ObjectType            # null when unknown
func all_types() -> Array[ObjectType]

func negotiate(id: String, requested_version: int) -> NegotiationResult
func register(type: ObjectType,
              host_scene_path: String = "",        # res://render/<type-id>/<type_id>_host.tscn
              client_scene_path: String = "",
              extension: ProtocolExtension = null) -> bool
NegotiationResult Meaning
OK Built-in matches or is forward-compatible.
UPGRADED_ON_LOAD App is newer than the package; in-memory migration applies.
INCOMPATIBLE Refuse the package.
UNKNOWN_TYPE Refuse the package.

The registry is immutable after startup in v1 — there is no runtime registration / unregistration. Each app's boot script 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. The app's boot script constructs its ObjectTypeRegistry.
  2. Each built-in type is registered (registry.register(TextObjectType.new(), "res://render/text/text_host.tscn", "res://render/text/text_client.tscn"), etc.). Registration order is non-significant but must be stable for reproducible test runs.
  3. At registration, the registry validates each runtime contract — instantiating each registered scene once (headless-safe) and asserting via has_method() that the root script exposes the full host_runtime / client_runtime surface, and the capture_state / restore_state pair where capabilities.is_stateful is true. A contract gap is a startup failure, not a slide-time surprise.
  4. Once registration is sealed, register 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, requested_version). If any returns INCOMPATIBLE or UNKNOWN_TYPE the package is refused and the user is shown the offending type id.
  3. For each slide, deserialise objectTypeData into the concrete generated data class for its type. The type-specific validate runs after deserialisation.
  4. Slides are now in memory and addressable.

Slide enter

  1. The slide runner instantiates the registered scene for each visible element (reveal.trigger == onEntry or visibility == always). Triggered elements are instantiated but kept hidden until their cue fires.
  2. on_slide_enter is called on each element's runtime in element-array order (render order — there is no zOrder field).

Reveal

  1. For trigger == afterDelay: the slide runner awaits a SceneTreeTimer for reveal.delayMs and then fires.
  2. For trigger == onCue: the runner 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. on_reveal is called; the runner plays the reveal.animation via a built-in Tween if animation != none.

Tick (Host only)

on_tick fires on every Host frame (≥ 60 Hz). The slide runner computes a TickInfo:

# Quiz.Stage/core/object_types/tick_info.gd
class_name TickInfo
extends RefCounted

var slide_elapsed_ms: int
var slide_remaining_ms: int
var is_locked: bool

Clients do not receive ticks — they reconcile against the Host's periodic time-remaining message instead (see Live Play Protocol — Timer authority).

Slide advance

on_advance is called on every element on the leaving slide. Stateful types are queried for a snapshot before the scene instance is freed.

Version negotiation

ObjectTypeRegistry.negotiate(id, requested_version) resolves as follows:

Built-in version Requested version Result Reason
N (any) unknown id UNKNOWN_TYPE App doesn't ship this type.
N N OK Exact match.
N < N UPGRADED_ON_LOAD App is newer; migrate(data, 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 UPGRADED_ON_LOAD 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 ObjectType declares (via capabilities.is_stateful) 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 pair on their runtime scene root — duck-typed, like the rest of the runtime contract — that the Host reads each time a snapshot is written.

# Additional duck-typed methods on the host_runtime scene root of a stateful type:

## Snapshot the live state of one element instance. Called on the Host main
## loop; must return within 1 ms (budget shared with other elements in the
## same snapshot pass). Return an empty Dictionary for stateless instances
## even on a stateful type (the runtime may opt out per-instance).
func capture_state() -> Dictionary

## Restore element state from a snapshot captured by an earlier process.
## Called on the Host main loop during recovery, before the element receives
## its first tick. The snapshot is the exact Dictionary returned by
## `capture_state`; interpretation happens inside the element implementation,
## not in `core/`.
func restore_state(snapshot: Dictionary) -> void

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 capture_state on each stateful element, and serialises the union into the on-disk JSON snapshot. On relaunch the Host calls restore_state per element after the slide loads, before any tick fires.

Rules:

The first stateful built-in is core.timer (Alpha) — its snapshot captures the live countdown position it is displaying on behalf of question.timing (the element still authors no duration of its own). MVP built-ins (core.text, core.multiple-choice, core.free-text, core.numeric, core.true-false, 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 host_runtime scene paths and optional ProtocolExtension. Owns the audience-facing render.
Client All built-ins with their client_runtime scene paths and optional ProtocolExtension. Filters at load — types whose capabilities.allowed_on_client_canvas == 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.
Preview (Stage app) Same as Host (for authoring preview parity). Web export preset; no networking. Same project, so "same as Host" is the same registration code path.
Remote No element registry — the Remote is Tauri + Angular and operates on envelope-level mirrors, not elements. The Remote's host-mirror is a scaled-down frame 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 pnpm schema:gen (Angular side) and the GDScript generator (python schemas/codegen/gen_gdscript.py) to produce the data types on both sides.
  3. Implement the ObjectType subclass in Quiz.Stage/core/object_types/core/{type_id}.gd (handwritten — wraps the generated data class).
  4. Add the 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. Build the host_runtime scene in Quiz.Stage/render/{type-id}/{type_id}_host.tscn (+ root script).
  8. Build the client_runtime scene in Quiz.Stage/render/{type-id}/{type_id}_client.tscn (+ root script).
  9. If the type exchanges wire messages, author the protocol message schemas under schemas/live-play/object-types/{type-id}/ and implement the ProtocolExtension subclass in Quiz.Stage/core/object_types/core/{type_id}_protocol.gd.
  10. If the type is a question type, additionally contribute the four question-level surfaces from the Question-type contract layer: the answerKey schema (package-format question node), the submission payload schema, the built-in answer-reveal presentation, and the Designer slide template the "+ Add slide" type picker instantiates.
  11. Register the type in each app's boot script (Stage registry + Angular ObjectTypeRegistry service).
  12. Add round-trip tests for the data class (serialisation, validation, migration from prior versions) on both Stage (GdUnit4) and Designer (Jest) sides, fed by shared fixtures in schemas/fixtures/object-types/{type-id}/.
  13. Add end-to-end test exercising a one-slide quiz containing only the new type — Playwright spec on the Designer side; GdUnit4 scene 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.

Mini-Game Framework

The architecture for embedding short, performative mini-games inside a quiz slide — surfaced to the author as the core.mini-game object type (Slide Schema — core.mini-game). The framework ships in Beta alongside one or two first-party mini-games built on it.

Concept

A mini-game is a 5-second-to-5-minute interactive segment that:

Mini-games are distinct from object types in that:

But they reuse the object-type plugin model: a core.mini-game element on the slide tells the Client which mini-game to load and how to configure it.

Registry

The mini-game registry mirrors ObjectTypeRegistry in shape but is keyed by miniGameId and has its own contract. The engine-free contract + registry live in Quiz.Stage/core/mini_games/ (sibling of core/object_types/); runtime scenes live in Quiz.Stage/render/mini_games/<id>/ and are registered as scene-path strings, exactly like object types.

# Quiz.Stage/core/mini_games/mini_game.gd
class_name MiniGame
extends RefCounted

var id: String                  # e.g. "core.mini-game.tile-tap"
var schema_version: int
var display_name: String

func default_config() -> Dictionary:
    assert(false, "subclass must override"); return {}

func validate(config: Dictionary) -> ValidationResult:
    assert(false, "subclass must override"); return null

The runtime surfaces are duck-typed scene-root contracts mirroring host_runtime / client_runtime (Object-Type Contract), validated by the registry at startup via has_method():

# render/mini_games/<id>/<id>_host.tscn — root script implements:
func on_start(ctx: MiniGameHostContext, config: Dictionary) -> void
func on_end(ctx: MiniGameHostContext, config: Dictionary) -> void
func on_tick(ctx: MiniGameHostContext, config: Dictionary, tick: TickInfo) -> void
func on_protocol_message(ctx: MiniGameHostContext, config: Dictionary, msg: ObjectMessage) -> void

# render/mini_games/<id>/<id>_client.tscn — root script implements:
func on_start(ctx: MiniGameClientContext, config: Dictionary) -> void
func on_end(ctx: MiniGameClientContext, config: Dictionary) -> void
func on_protocol_message(ctx: MiniGameClientContext, config: Dictionary, msg: ObjectMessage) -> void
# Quiz.Stage/core/mini_games/mini_game_host_context.gd
class_name MiniGameHostContext
extends HostElementContext

var scorer: MiniGameScorer
var clock: MiniGameClock

MiniGameScorer is the per-event / per-final scoring surface — the mini-game emits scoring events via scorer.award(team_id, delta, reason) which the Host turns into scoreUpdate envelopes per Live Play Protocol.

MiniGameClock is the Host-authoritative mini-game timer — independent of the slide timer, with the same tick cadence + reconciliation rules as the slide timer.

Lifecycle

sequenceDiagram autonumber participant Host participant Client Host->>Client: slideEnter (slide carrying core.mini-game element) Note over Client: Client instantiates the mini-game scene.
on_start on Client runtime. Note over Host: Host instantiates its mini-game scene.
on_start on Host runtime.
Scorer + Clock initialised. Host-->>Client: timerTick (mini-game clock) every 200 ms Client->>Host: objectMessage { kind: "event", body: ... } Note over Host: Routes to mini-game on_protocol_message.
scorer.award(team_id, delta, reason) Host-->>Client: scoreUpdate Note over Host: durationMs elapses Host-->>Client: miniGameEnd Note over Client: on_end on Client. Note over Host: on_end on Host.
Final scores settled.
Slide auto-advances to next slide unless configured otherwise.

Configuration shape

Per the Slide Schema — core.mini-game, the slide-level objectTypeData carries the mini-game id, its version, and an opaque config payload owned by the named mini-game. Two MVP-bundled mini-games (TBD during Beta build-plan work — examples below):

core.mini-game.tile-tap (example)

A grid of tiles flashes a target colour briefly; teams tap matching tiles for points within the duration.

"config": {
  "gridRows": 4,
  "gridCols": 4,
  "targetColour": "#FF009F",
  "decoyColours": ["#16B2EB", "#961EEF"],
  "flashDurationMs": 600,
  "flashIntervalMs": 1200,
  "pointsPerHit": 1,
  "pointsPerMiss": -1
}

core.mini-game.beat-the-bar (example)

A bar drifts left to right; teams tap when it reaches the green zone for variable points based on accuracy.

"config": {
  "barSpeedPxPerSec": 600,
  "greenZoneWidthPct": 12,
  "yellowZoneWidthPct": 24,
  "greenPoints": 3,
  "yellowPoints": 1,
  "missPoints": 0,
  "roundsPerGame": 5
}

Both are illustrative — the actual first-pair-bundled mini-games are a Beta build-plan deliverable.

Adding a mini-game

Same checklist as adding a new object type, with mini-game substitutions:

  1. Author the config schema under schemas/object-types/core.mini-game/<id>/ and regenerate (pnpm schema:gen + python schemas/codegen/gen_gdscript.py).
  2. Implement the MiniGame subclass in Quiz.Stage/core/mini_games/<id>.gd and the host / client runtime scenes in Quiz.Stage/render/mini_games/<id>/.
  3. Add the config schema to this page (or a child page once the framework has more than ~3 built-ins).
  4. Add the mini-game id to the build-plan's mini-game cohort list.
  5. Register in each app's boot script (Host + Client).
  6. Round-trip tests for the config (GdUnit4 + Jest, shared fixtures).
  7. End-to-end test exercising a one-slide quiz containing a core.mini-game element pointing at the new mini-game.

Protocol

Mini-game traffic flows on the live-play family using the objectMessage envelope per Live Play Protocol — Object-Message Extensions, with:

The Host emits a synthetic miniGameEnd envelope at duration-up:

{ "type": "objectMessage", "payload": {
    "objectTypeId": "core.mini-game",
    "messageNamespace": "core.mini-game",
    "elementId": "el-...",
    "kind": "miniGameEnd",
    "body": { "finalScores": { "team-...": 7, "team-...": 4 } }
} }

Slide Schema

Domain decisions: Question Model — slide kinds, the first-class question node, answer-key custody, and the MVP question-type catalogue are decided there; this page is the field-level rendering of those decisions.

The field-level JSON schema for everything inside a .quiz package: the manifest, slides, the question node, canvases, elements + components, reveal, and every built-in object type's payload. This is the canonical contract — the generated GDScript types in Quiz.Stage/core/generated/ and the generated TypeScript types in the Angular workspaces reflect this page, and Quiz Package Format describes how these JSON files are bundled on disk.

Implementation status: the shipped schemas/package-format/ files and generated code predate this revision (slide-level timing/scoring, no kind/question node, region-based client canvas). Migration is tracked in the build plan — Question-first slide model. Pre-alpha rule applies: schemas are rewritten in place, no compat shims, old dev .quiz files are discarded.

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 single source of slide order. The runtime traverses slides in this order; reorder = rewrite this array. Slides carry no index of their own. Min 1, max 500. Each id must match a file under slides/<slideId>.json.
rounds object[] optional Round metadata only — see Rounds. Membership lives on slide.roundId, never here.
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 split order and membership across two single sources of truth (Question Model — structural fixes):

"rounds": [
  {
    "id": "1f...e7",
    "title": "General Knowledge",
    "description": "Ten questions, no theme, no mercy.",
    "scoring": { "basePoints": 1, "speedBonus": { "enabled": false, "maxBonusPoints": 0, "curve": "linear" }, "tiebreak": "fastest" },
    "customIntro": false
  }
]
Field Type Required Description
id UUIDv4 Stable round id. Survives reorder.
title string Author-facing label. 1–60 chars. Drives the Host's generated round-intro splash.
description string optional 0–500 chars. Second line of the generated splash.
scoring object optional Round-level defaults — same shape as the Scoring block. A question without its own scoring inherits this.
customIntro bool optional Default false. true suppresses the Host's generated round-intro splash when the round's first slide is an authored interstitial.

Round contiguity is a validator rule: the slides sharing a roundId must form a contiguous run in manifest.slideOrder. Overlapping or interleaved rounds are rejected at save/export.

Object-Type Declaration

"objectTypes": [
  { "id": "core.text", "schemaVersion": 1 },
  { "id": "core.multiple-choice", "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. Every slide is either a question or an interstitial (Question Model — slide kinds). There is no slide-level timing or scoring (both live on the question node) and no slide.index (order is manifest.slideOrder).

{
  "id": "a1...b2",
  "schemaVersion": 1,
  "kind": "question",
  "title": "Capital of France?",
  "roundId": "1f...e7",
  "hostNotes": "**Answer:** Paris. If teams say *London*, that's a classic dad-joke setup — let them have the laugh.",
  "question": {
    "questionType": "core.multiple-choice",
    "prompt": "What is the capital of France?",
    "answerKey": { "correctOptionId": "opt-a" },
    "scoring": { "basePoints": 1, "speedBonus": { "enabled": true, "maxBonusPoints": 2, "curve": "linear" }, "tiebreak": "fastest" },
    "timing": { "durationMs": 30000, "lockOnTimeUp": true, "quizmasterOverrideAllowed": true, "lateSubmission": { "policy": "reject" } },
    "inputBinding": "el-7c...91"
  },
  "hostCanvas":   { "background": { "kind": "solid", "colour": "#0F0B1A" }, "elements": [ ...elementBlocks... ] },
  "clientCanvas": { "background": { "kind": "themeDefault" }, "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.
kind enum question or interstitial. Drives the validator (see below) and the Host's per-slide state machine (interstitials collapse to show → advance).
title string Author-facing label. Appears in slide rail and Designer outliner. 1–120 chars. Never renders to audience.
roundId UUIDv4 optional The single source of round membership. If set, must reference a manifest.rounds[].id; the round's slides must be contiguous in slideOrder.
hostNotes string optional Markdown. Rendered only on Remote + Host operator window during play, never on audience-facing surfaces. Max 4000 chars.
question object conditional Required iff kind == "question"; forbidden on interstitials. See Question node.
hostCanvas object The TV-facing canvas — see Canvases.
clientCanvas object The phone-facing canvas — same shape, see Canvases.

Question node

The first-class domain object the whole stack hangs off (Question Model — the model). slideId doubles as the question id everywhere — protocol, results, scoring.

Field Type Required Description
questionType string One of the catalogue ids (core.multiple-choice, core.free-text, core.first-letter, core.numeric, core.true-false, core.ranking, core.eliminator, core.buzzer). The bound input element's type is the same id — question type and input element type share one identifier (validator-enforced).
prompt string Canonical question text — operator surfaces, results export, a11y announcements. 1–1000 chars. Canvas text elements display it (template-bound by default, override allowed); they are never its source of truth.
answerKey object conditional Type-specific shape — see Answer-key shapes. Required for every type except core.buzzer (which has none — the quizmaster adjudicates). Never sent to Clients: the Host strips every answerKey at package-load time; the eager push carries the stripped package (Question Model — answer-key custody).
scoring object optional See Scoring block. Absent → the round's scoring defaults apply; the validator rejects a question covered by neither.
timing object See Timing block.
inputBinding UUIDv4 elementId of the single input element on clientCanvas — see Input elements.

Answer-key shapes

Per-type, from the Question Model catalogue. All 8 ship in MVP.

questionType Answer key shape Auto-marked
core.multiple-choice { "correctOptionId": string } — must reference an options[].id on the bound input element yes
core.free-text { "acceptedAnswers": string[], "matchMode": "exact" \| "caseInsensitiveExact" \| "caseInsensitiveTrim" \| "regex" }acceptedAnswers non-empty; regex patterns implicitly anchored (^…$) yes (+ Alpha review queue)
core.first-letter { "correctLetter": "A"–"Z" } — must be within the element's allowedLetters subset when one is set yes
core.numeric { "targetValue": float, "proximity": "exact" \| "closestAbsolute" \| "closestRelative", "equalDistanceRule": "firstSubmitWins" \| "splitPoints" \| "allTied" }closestRelative is undefined when targetValue == 0 (validator rejects) yes
core.true-false { "correctAnswer": bool } yes
core.ranking { "correctOrder": itemId[], "credit": "allOrNothing" \| "perPosition" }correctOrder is a permutation of the element's items[].id yes
core.eliminator { "correctOptionId": string, "eliminationSchedule": [{ "atMs": integer, "removeOptionIds": string[] }] } — schedule may be empty (quizmaster-cued eliminations only); may never remove correctOptionId yes
core.buzzer none — verbal answer no — quizmaster adjudicates via Correct / Incorrect operator buttons

core.timer and core.leaderboard are display elements, not question types — placeable on either canvas of either slide kind (timer only meaningful on timed question slides).

The numeric key's proximity (match rule) and scoring.tiebreak (session-level tie resolution) are distinct concepts with distinct names — the old tiebreakerRule collision is retired.

Scoring block

One unified shape; attaches to question.scoring, with round defaults in manifest.rounds[].scoring. Interstitials cannot score.

"scoring": {
  "basePoints": 1,
  "speedBonus": { "enabled": true, "maxBonusPoints": 2, "curve": "linear" },
  "tiebreak": "fastest"
}
Field Type Required Description
basePoints integer Points for a correct answer. Default 1. Range −100…100. Negative is legal (the "trick" round).
speedBonus.enabled bool Default false.
speedBonus.maxBonusPoints integer Bonus at instant answer, decaying to 0 over the answer window. Range 0…100.
speedBonus.curve enum linear (MVP — linear decay over the answer window). Beta adds easeOut, stepped as additional values on this one field — there is no second element-level speed-bonus concept.
tiebreak enum Session-level tie resolution: fastest (Host receive-order wins), shared (tied teams each take full points), host-decides (operator picks). Default fastest.

The Host's receive timestamp is authoritative for speed bonuses and buzzer ordering; clientSubmittedAtMs is advisory telemetry only.

Timing block

One unified shape; attaches to question.timing. Interstitials cannot lock. The old split (timing.lateSubmissionRule enum + separate scoring.lateSubmissionPenalty) is retired; "per-second decay" is cut (re-proposable as a later policy).

"timing": {
  "durationMs": 30000,
  "lockOnTimeUp": true,
  "quizmasterOverrideAllowed": true,
  "lateSubmission": { "policy": "accept-penalty", "windowMs": 5000, "penaltyFraction": 0.5 }
}
Field Type Required Description
durationMs integer Question 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.
quizmasterOverrideAllowed bool If true, the operator + paired Remote can extend / skip / manually lock / manually unlock the timer. Default true.
lateSubmission.policy enum reject (default), accept-full (accept and score normally), accept-penalty.
lateSubmission.windowMs integer conditional Required when policy != reject. How long after lock submissions are still considered. Range 1000…60000.
lateSubmission.penaltyFraction float conditional Required when policy == accept-penalty. Fraction of the marked points deducted. Range 0…1.

If durationMs is 0 (untimed), the remaining fields are ignored on load but must still be present (default values).

Canvases

Both canvases use the same element model. A canvas is a background plus an ordered element array; every element positions itself via its core.rect-transform component (anchors + pivot + offsets — see Element block). The differences are resolution context only:

"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 { "kind": "themeDefault" } (also the absent default). Per-canvas — host and client backgrounds are independent.
elements object[] Ordered list of placed elements. Render order is array order — later renders on top. There is no zOrder field; the Designer's bring-forward / send-back commands are array moves.

There are no regions, stacks, or per-canvas transform variants — that system was deleted from the spec (Question Model — structural fixes); the rect-transform's anchor model already expresses responsive layout.

Input elements

Input elements (their type is the question-type id itself — e.g. core.multiple-choice; there is no separate -input id) carry presentation config only — option labels and ids, layout, shuffle flags, keypad style. Option identity (optionId) lives in the element so submissions reference it; option correctness lives in question.answerKey and never ships to Clients. Validator rules:

Element block

Every element on either canvas. The shared block is identical across object types; all type-specific data lives in typed components on components[] (see Object-Type Contract — Elements own a list of typed components).

{
  "id": "el-...",
  "type": "core.multiple-choice",
  "typeVersion": 1,
  "name": "Q1 options",
  "components": [
    {
      "componentType": "core.rect-transform",
      "data": {
        "x": 240, "y": 180, "width": 1440, "height": 240, "rotation": 0,
        "anchorMin": { "x": 0.5, "y": 0.5 },
        "anchorMax": { "x": 0.5, "y": 0.5 },
        "pivot":     { "x": 0.5, "y": 0.5 }
      }
    },
    {
      "componentType": "core.multiple-choice",
      "data": { ... }
    }
  ],
  "visibility": "always",
  "reveal": { "trigger": "onEntry", "delayMs": 0, "animation": "none" },
  "lock": false,
  "notes": ""
}
Field Type Required Description
id UUIDv4 Stable per-element. Used by question.inputBinding, 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.
components object[] Typed components: [0] = core.rect-transform, exactly one primary component matching type. Each entry is { "componentType": string, "data": object }.
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.

The element does not carry a canvas field — the containing array (hostCanvas.elements vs clientCanvas.elements) is the single source of which canvas it lives on.

core.rect-transform

The mandatory first component. Maps naturally onto Godot Control anchors at render time.

Field Type Required Description
x, y float Pixel offset of the pivot from the anchor point. Host canvas: virtual-canvas pixels (off-canvas placement is legal for animation entry points). Client canvas: viewport pixels at the resolved anchor.
width, height float Element size in the same pixel space. Min 8 × 8.
rotation float Degrees, clockwise from horizontal. Range −180…180. Default 0.
anchorMin, anchorMax { x, y } Canvas-fraction anchors, each component 0..1, anchorMin ≤ anchorMax per axis. Equal anchors = fixed-size element pinned to that fraction; unequal anchors = element edges track the canvas (responsive stretch).
pivot { x, y } 0..1 within the element's own rect. Rotation and x/y offsets apply about this point. Default { 0.5, 0.5 }.

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 Easing curve name (linear, easeInQuad, easeOutQuad, easeInOutCubic, …). Default easeOutCubic. The full enum list is pinned in schemas/; the shared reveal player in Quiz.Stage/render/ maps each name onto Godot's built-in Tween transition + ease constants.

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.

Note: answer reveal is not authored via this block — it is a first-class Host behaviour driven from answerKey on quizmaster cue (Question Model — marking, reveal, results). The eliminator's option-removal timeline is answerKey.eliminationSchedule, not element reveals.

Per-Type Schemas

Each object type pins the data shape of its primary component here. Input types carry presentation only — every correctness field has moved to question.answerKey. The shapes below cover the MVP cohort: core.text, the eight question-type input elements (element type = the questionType id), core.timer, core.leaderboard. Alpha + Beta types are documented as they land; this page is the canonical home for every type's payload (cross-referenced from Object-Type Architecture — Built-in catalogue).

core.text

"data": {
  "schemaVersion": 1,
  "text": "Capital of France?",
  "promptBound": true,
  "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). Ignored at render when promptBound is true.
promptBound bool If true (the scaffold default on question slides), the element renders question.prompt live — editing the prompt updates every bound element. false = freeform text override. Always false on interstitials.
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 canvas's resolved 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

"data": {
  "schemaVersion": 1,
  "options": [
    { "id": "opt-a", "label": "Paris"  },
    { "id": "opt-b", "label": "London" },
    { "id": "opt-c", "label": "Berlin" },
    { "id": "opt-d", "label": "Madrid" }
  ],
  "shuffleOptions": true,
  "showLetters": true
}
Field Type Required Description
options[].id string Stable per-option id. Kebab-case. Submissions reference it; correctness lives in answerKey.correctOptionId.
options[].label string Option text. 1…200 chars.
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

"data": {
  "schemaVersion": 1,
  "maxLength": 80,
  "placeholder": "Your answer…"
}
Field Type Required Description
maxLength integer Client-side input cap. Default 80. Range 1…500.
placeholder string optional Client-side input placeholder. Default empty.

Accepted answers + match mode live in answerKey — they never reach the Client.

core.first-letter

"data": {
  "schemaVersion": 1,
  "layout": "threeRow",
  "allowedLetters": null
}
Field Type Required Description
layout enum A–Z grid arrangement: threeRow (default), fourRow. Visual details land with the Client UI chunk.
allowedLetters string[] | null optional Constrains the grid to a subset (each entry a single A–Z char, ≥ 2 entries). null = full alphabet. answerKey.correctLetter must be within the subset.

Single-pick: tapping a letter is the submission (last-write-wins until lock, like every non-buzzer type).

core.numeric

"data": {
  "schemaVersion": 1,
  "unit": "°C",
  "decimalPlaces": 0,
  "allowNegative": false,
  "min": null,
  "max": null
}
Field Type Required Description
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.

Target value + proximity rule + equal-distance resolution live in answerKey.

core.true-false

"data": {
  "schemaVersion": 1,
  "trueLabel": "True",
  "falseLabel": "False"
}
Field Type Required Description
trueLabel, falseLabel string Author-overridable button labels. Default True / False. 1…30 chars.

The correct answer lives in answerKey.correctAnswer.

core.ranking

"data": {
  "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 }
  ],
  "shuffleOnPresent": true
}
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.
shuffleOnPresent bool If true, the Client renders a per-team random initial order. Default true.

Correct order + credit mode (allOrNothing / perPosition) live in answerKey.

core.eliminator

"data": {
  "schemaVersion": 1,
  "options": [
    { "id": "opt-a", "label": "Paris"  },
    { "id": "opt-b", "label": "London" },
    { "id": "opt-c", "label": "Berlin" },
    { "id": "opt-d", "label": "Madrid" }
  ],
  "shuffleOptions": false
}
Field Type Required Description
options[].id string Stable per-option id. Kebab-case. Referenced by answerKey.eliminationSchedule[].removeOptionIds.
options[].label string 1…200 chars. Range 3…8 options.
shuffleOptions bool Default false — elimination beats usually read better in a fixed order.

Teams may answer at any point; earlier answers score more via the speed bonus. Option removal is driven by answerKey.eliminationSchedule (timeline) or quizmaster cue — the Host emits removals to Clients; nothing about correctness or the schedule ships in the eager push. The final-reveal beat is quizmaster-cued.

core.buzzer

"data": {
  "schemaVersion": 1,
  "buttonLabel": "BUZZ!",
  "armDelayMs": 0,
  "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.
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.

First press locks all buzzers and fires the team's jingle; the quizmaster adjudicates via Correct / Incorrect; incorrect → that team is excluded and buzzers re-open for the rest (Question Model — mechanics). The press is final — no resubmission. Press message: objectMessage { kind: "press", body: { pressedAtClientMs: 12345 } }; the Host's receive timestamp disambiguates ties.

core.timer

A view of question.timing — the element carries no duration of its own. Valid only on slides whose question has durationMs > 0; the validator warns otherwise. Interstitials cannot host timers.

"data": {
  "schemaVersion": 1,
  "mode": "countdown",
  "display": {
    "format": "mmss",
    "showMilliseconds": false,
    "warningAtMs": 5000,
    "warningStyle": "pulse"
  }
}
Field Type Required Description
mode enum countdown (counts down from question.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.

Timer authority + tick cadence are protocol concerns — see Live Play Protocol — Timer authority.

core.leaderboard

"data": {
  "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 primary-component payloads:

core.image

"data": {
  "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

"data": {
  "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

"data": {
  "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.

Beta + Stretch-cohort schemas

These types land in Beta or Stretch — phase per type in the catalogue. The core.categories and core.match-pairs payloads below predate the answer-key split and will be revised to move correctness fields into question.answerKey when their cohorts land — recorded here so the shapes aren't lost in the meantime:

core.drawing

The drawing question type is Stretch — moved out of v1. Drawing is quizmaster-marked (no auto-markable answer key); its questionType joins the catalogue when it lands.

"data": {
  "schemaVersion": 1,
  "canvasAspect": "16:9",
  "strokeWidthPx": 6,
  "strokeColour": "#FFFFFF",
  "backgroundColour": "#1F1933",
  "allowEraser": true,
  "mirrorToHost": true
}
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.

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

"data": {
  "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 the free-text answer key. 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

"data": {
  "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

"data": {
  "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 question.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.

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, implemented in TypeScript on the Designer side and mirrored in Quiz.Stage/core/packaging/ for Host-side load, both asserted against shared fixtures in schemas/fixtures/).
  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 is generated from schemas/ on both sides — ajv in the Designer (runs on save; binding violations also render as blocking badges in the slide rail and block export), GDScript in Quiz.Stage/core/generated/ (Host on load, 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 Quiz.Stage/tests/core/ (GdUnit4) and the Designer's Jest suite, fed by shared fixtures in schemas/fixtures/. (Round-trip applies to the authoring artifact; the live-play eager push is a derived, key-stripped projection and is not round-tripped.)

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; Godot TCPServer + WebSocketPeer in the Host, WebSocketPeer client in the Client, the browser WebSocket API via rxjs/webSocket in the Tauri + Angular Designer and Remote) is described in Networking and Tech Stack.

The question-shaped semantics this protocol carries — question identity (slideId doubles as the question id), answer-key custody, submission shapes, marking, adjudication, and answer reveal — are pinned in the Question Model; this page conforms to that decision record.

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.

Team lifecycle

Message families

live-play (Client ↔ Host)

Direction Type Purpose
H → C sessionHello, helloAck, sessionSnapshot, teamRosterUpdate, slideEnter, slideAdvance, elementReveal, answerReveal, 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.

The pushed bytes are the stripped package — Clients never receive answer keys. At package-load time the Host removes every question.answerKey from the quiz into a session-side answer table (Host memory + session snapshot); the eager-push body is serialised from that stripped package. Consequently a Client cannot pre-validate a submission locally — every mark round-trips through the Host (answerSubmitscoreUpdate) — and a compromised Client device reveals nothing. Custody decision and accepted trade-offs: Question Model — Answer-key custody.

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

Interstitial slides. A slideEnter for a slide with kind: "interstitial" (per the pushed package) carries no timer/lock lifecycle: the Host emits no timerTick / timerLock / timerUnlock for it and rejects any answerSubmit addressed to it (protocolError code: validationFailed — interstitials have no question). The Host's per-slide state machine collapses to show → advance; the only frames an interstitial produces are slideEnter, any elementReveals, and slideAdvance.

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

answerSubmit is element-addressed on the wire but semantically question-addressed: with exactly one question per slide (Question Model), slideId is the question id. The Host resolves the bound input element via question.inputBinding and rejects the frame (protocolError code: validationFailed) if elementId does not match the binding or the slide is an interstitial.

submission is type-specific, one shape per MVP question type:

questionType submission
core.multiple-choice { "optionId": "opt-a" }
core.free-text { "text": "Paris" }
core.first-letter { "letter": "P" }
core.numeric { "value": 100 }
core.true-false { "value": true }
core.ranking { "order": ["item-3", "item-1", "item-2"] } (full permutation of the element's item ids)
core.eliminator { "optionId": "opt-c" } (must be an option not yet eliminated)
core.buzzer — no answerSubmit; a buzzer press travels as an objectMessage with kind: "press" (see Object-Message Extensions), as before

The Host validates the submission against the resolved input element's presentation config (option/item ids) and the current timer state, marks it against the session-side answer table (question.answerKey — never on the Client), applies question.scoring (including question.timing.lateSubmission if the lock has fired), and emits a scoreUpdate.

Resubmission policy: last-write-wins until lock, for every question type except buzzer — a buzzer press is final. An answerSubmit for a question whose input is already locked is refused with protocolError code: inputLocked.

Clock authority. The Host's receive timestamp is authoritative for speed bonuses and buzzer ordering; clientSubmittedAtMs is advisory telemetry only (client clocks are unauthenticated).

scoreUpdate
{ "type": "scoreUpdate", "payload": {
    "teamId": "team-...",
    "slideId": "sl-...",
    "delta": 1,
    "newTotal": 7,
    "reason": "correct" 
} }

reason: correct, incorrect, latePenalty, adjudicated, quizmasterOverride, revert.

answerReveal
{ "type": "answerReveal", "payload": {
    "slideId": "sl-...",
    "presentation": { ... }
} }

Emitted H → C and H → R on quizmaster cue (the Reveal answer operator button, or the equivalent Remote command). presentation is a type-specific reveal payload the Host derives entirely from the session-side answerKey — each question type ships a built-in answer presentation (e.g. multiple-choice: { "correctOptionId": "opt-a" } highlights the correct option; free-text: { "canonicalAnswer": "Paris" } shows the canonical accepted answer). Authors never hand-place duplicate answer text; custom author-styled reveal overlays are Beta. See Question Model — Marking, reveal, results.

objectMessage (Object-Message Extensions)

Object types with a ProtocolExtension (see Object-Type Contract) send and receive via:

{ "type": "objectMessage", "payload": {
    "objectTypeId": "core.buzzer",
    "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 (ObjectTypeRegistry.get_type(objectTypeId)), whose ProtocolExtension deserializes the body into its ObjectMessage type, then calls on_protocol_message on the element's runtime scene. 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, inputLocked, late, nameTaken, 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, answerReveal, timerTick, timerLock, timerUnlock, scoreUpdate, leaderboardSnapshot, hostMirrorFrame, hostNotes, protocolError, sessionEnd Mirror + telemetry.
R → H hello, pong, advance, goBack, adjudicate, jumpToSlide (Alpha+), cueReveal (Alpha+), lockInput (Alpha+), unlockInput (Alpha+), extendTimer (Alpha+), skipTimer (Alpha+), overrideScore (Alpha+), removeTeam (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).

adjudicate
{ "type": "adjudicate", "payload": {
    "slideId": "sl-...",
    "teamId": "team-...",
    "verdict": "correct"
} }

verdict: correct | incorrect. R → H, and the same command shape is raised in-process by the Host operator window's Correct / Incorrect buttons — MVP's only manual-marking surface, scoped to core.buzzer questions.

Buzzer flow (MVP). The first objectMessage press the Host receives locks all buzzers (Host receive order is authoritative — see answerSubmit clock authority) and plays the pressing team's jingle on the Host. The team answers verbally; the quizmaster adjudicates via Correct / Incorrect, producing an adjudicate frame. correct → the Host scores per question.scoring and emits scoreUpdate with reason: adjudicated. incorrect → that team is excluded from the question (optional, Host-configurable penalty applied via the same scoreUpdate path) and buzzers re-open for the remaining teams. A press is final — no resubmission.

Alpha generalises adjudicate for the marking queue: the operator window lists per-team submissions for the current question (free-text especially) with accept/reject toggles, each toggle an adjudicate that re-scores retroactively. This is the concrete surface of live answer-correction — editing the answerKey and re-running the marker are the same operation. See Question Model — Marking, reveal, results.

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 question's timing.lateSubmission.policy != "reject" (and within windowMs); otherwise the Host emits a protocolError with code: late to the originating Client.

Untimed slides

question.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). (Interstitial slides have no timing at all — see slideEnter.)

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 slide runner; types flagged capabilities.is_stateful 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 slide runner calls restore_state(snapshot) on each stateful element's runtime scene to feed it its prior snapshot — see Object-Type Contract — Statefulness.

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.

Results record (Alpha)

The Host keeps a per-question results record inside the session snapshot — for each question slide, a per-team row:

"resultsBySlideId": {
  "sl-...": {
    "team-...": {
      "submission": { "optionId": "opt-a" },
      "verdict": "correct",
      "points": 3,
      "hostReceivedAtMs": 28510
    }
  }
}

verdict reflects the latest marking outcome (auto-mark or adjudicate — retroactive re-scores overwrite the row); hostReceivedAtMs is the authoritative receive timestamp used for speed bonuses. At session end the Host can export the session as JSON — final standings plus per-round and per-question tables. This is the seed for Stretch analytics as a local file the quizmaster owns; see Question Model — Marking, reveal, results.

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", "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 the generated schema validators from Quiz.Stage/core/generated/. 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 exclusively on the project's self-hosted build farm (see Build agents for the farm topology). The only Microsoft-hosted use is the lint-only quiz-designer-lint.yml PR check on windows-latest.

Pool + routing

All production pipelines target the single pool SelfHosted and route to the right ephemeral agent via an Agent.OS demand. Pipelines never reference agent names. See Build agents — Routing rules for the rationale.

pool:
  name: SelfHosted
  demands:
    - Agent.OS -equals Linux        # or Darwin / Windows_NT

Layout — templates + per-app pipelines

The repo's pipeline files live under .azure-pipelines/. The layout splits reusable templates from per-app pipelines so other projects on the same farm can consume the templates verbatim via a resources.repositories reference:

.azure-pipelines/
├── templates/
│   ├── platform-pool.yml         ← chooses pool + demands by platform name
│   ├── godot-setup.yml           ← download + cache the pinned Godot editor + export templates
│   ├── godot-test.yml            ← headless import + GdUnit4 run, params: project path
│   ├── godot-export.yml          ← one preset export, params: project / preset / output
│   ├── tauri-build.yml           ← one Tauri build, params: project / target / artifact
│   ├── tauri-mobile-build.yml    ← `tauri ios|android build`, params: project / target
│   ├── fastlane-ios.yml          ← sign + upload via fastlane match + ASC API
│   └── android-sign.yml          ← sign apk/aab using keystore from variable group
├── quiz-designer.yml             ← Tauri matrix: windows (linux / macos commented until signing lands)
├── quiz-designer-lint.yml        ← PR-only lint + format + clippy (windows-latest)
├── quiz-stage.yml                ← Godot: test job + export matrix (host win/mac · client android/ios · preview web)
└── quiz-remote.yml               ← Tauri mobile matrix: android / ios

Reusable templates

Each template takes structural parameters (project path, preset, output paths) plus the variable-group names it consumes. They are written once and called by every per-app pipeline. Other projects pull them by adding a resources.repositories ref to this repo:

resources:
  repositories:
    - repository: quiz
      type: git
      name: Quiz/Quiz
      ref: refs/heads/master

steps:
  - template: .azure-pipelines/templates/tauri-build.yml@quiz
    parameters:
      project: MyApp.Designer
      target: x86_64-unknown-linux-gnu

godot-setup.yml is version-pinned: a single GODOT_VERSION template parameter selects the editor binary and matching export templates, both downloaded from the official mirrors and restored from the pipeline cache on subsequent runs — see Build agents — Caching policy. Nothing Godot-related is baked into the agent images.

Per-app pipelines

Pipeline Jobs Build kind
quiz-stage.yml Test job (Linux): headless import → GdUnit4 full suite. Export matrix, each platform an independent job: host-windows (Windows agent), host-macos (Darwin agent, signed + notarised), client-android (Linux agent → android-sign → Supply), client-ios (Darwin agent → fastlane), preview-web (Linux agent — published as the pipeline artefact the Designer build consumes into src/assets/preview/). godot --headless --export-release <preset> per the export presets in Quiz.Stage/export_presets.cfg.
quiz-remote.yml android (Linux agent), ios (Darwin agent) Tauri mobile: cargo tauri android|ios build, then the matching signing template.
quiz-designer.yml Windows debug only today on self-hosted windows-builder. Linux, macOS, iOS-companion jobs commented out — uncomment as each signing track lands. Tauri 2 bundle (NSIS .exe on Windows).
quiz-designer-lint.yml PR-only quality gates Lint + format + clippy on Microsoft-hosted windows-latest.

Within each matrix, every platform is a separate independent job — Linux + macOS + Android complete even when the Windows job queues waiting for a Windows agent.

quiz-designer-lint.yml runs the fast quality gates from CLAUDE.md on PRs targeting master. It uses Microsoft-hosted windows-latest (free quota) so a Windows host isn't required for PR turnaround.

The quiz-stage.yml export jobs schedule GdUnit4 as a gating prerequisite — exports only run when the test job is green. The Stage test invocation is pinned in Testing — CI gate.

Variable groups

Per-pipeline secrets live in ADO variable groups, never in the agent images. See Build agents — Secrets the agents do not carry.

Godot needs no licence activation — the headless binary imports, tests, and exports without credentials, which is one of the reasons it was chosen (Decisions).

Triggers:

Caching

Centralised cache decisions live in Build agents — Caching policy. Pipelines pull cache by lock-file / version-pin hash; nothing is shared across ephemeral runs that can be rebuilt from sources.

Build approach (no hand-written build logic)

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.

Build agents

The project ships through a self-hosted Azure DevOps build farm spread across two host machines: a Mac mini (Linux container + macOS VM) and a Windows host (Hyper-V Windows VM). This page is the architecture-level summary; the operational runbooks live outside the knowledgebase:

Edit this page when the shape of the farm changes (agents added or removed, pool renamed, OS targets gained or dropped). Edit the runbooks for ops-only detail.

Topology

Both hosts run ephemeral agents — each registers, runs exactly one pipeline job, then destroys itself; a host-side loop immediately recreates a fresh replacement.

Agent name Kind Host Capabilities reported Role
ubuntu-mac-mini Docker container (Colima daemon, linux/amd64 under VZ-Rosetta) Mac mini M4 Agent.OS=Linux Linux Tauri bundles, Godot Android exports (Stage Client), Android Tauri APKs (Remote), Godot web exports (Stage Preview), GdUnit4 test job, schema codegen — anything that runs on Ubuntu 22.04 with Node 22 + Rust + Tauri CLI + Angular CLI + the Android SDK/NDK + JDK.
macos-mac-mini Tart VM clone of the ado-mac-base golden image Mac mini M4 Agent.OS=Darwin macOS Tauri DMGs, iOS Tauri bundles (Remote), Godot macOS exports (Stage Host), Godot iOS exports (Stage Client), code-signing + notarisation, fastlane uploads. Carries Xcode 26.4.1 + fastlane + cocoapods.
windows-builder Hyper-V VM clone of C:\ci\golden\golden.vhdx (Win11 Pro 25H2, Gen2, 12 GB RAM, 6 vCPU, attached to CI-Switch external vSwitch). Loop driven by CI-WinAgentLoop NSSM service under LocalSystem. Windows host REGGIESPC (GMKtec NucBox K8 Plus, Win11 Pro, 16 logical CPUs / 28.8 GB) Agent.OS=Windows_NT Windows Tauri bundles (NSIS), Godot Windows exports (Stage Host). Carries Git 2.54 + Node v22.22.3 + Rust/cargo 1.96 (msvc target) + VS 2022 Build Tools (VCTools) + WebView2 + Angular CLI 21.2.14 + Tauri CLI 2.11.2 + Azure agent 4.274.1 + PowerShell 7.6.2.

All three register against the single Azure DevOps pool SelfHosted (id 12, org https://dev.azure.com/adamlewishome). Pipelines route to the right agent via demands: on Agent.OS — never via pool selection.

What each agent can build

Surface Agent Notes
Quiz.Designer — Linux Tauri AppImage / .deb ubuntu-mac-mini tauri build Linux target. Requires libwebkit2gtk-4.1 + the GTK stack baked into the container.
Quiz.Designer — macOS Tauri DMG macos-mac-mini tauri build macOS arm64 (M4 is arm64-only; universal needs rustup target add x86_64-apple-darwin baked into the golden VM first).
Quiz.Designer — Windows Tauri NSIS windows-builder tauri build Windows MSVC target. Toolchain (Rust + VCTools + WebView2 + Node 22 + Tauri CLI) already baked into the golden VHDX.
Quiz.Stage — GdUnit4 test job ubuntu-mac-mini godot --headless --import + GdUnit4 CLI; gating prerequisite for every export job.
Quiz.Stage — Host Windows export windows-builder godot --headless --export-release host-windows.
Quiz.Stage — Host macOS export macos-mac-mini godot --headless --export-release host-macos, signed + notarised.
Quiz.Stage — Client Android export ubuntu-mac-mini godot --headless --export-release client-android; android-sign template; fastlane Supply for Play Console upload. See the ETC2/ASTC note below.
Quiz.Stage — Client iOS export macos-mac-mini Godot iOS export → Xcode project → fastlane build/sign/upload.
Quiz.Stage — Preview web export ubuntu-mac-mini godot --headless --export-release preview-web; consumed by the Designer Tauri build as a pipeline artefact.
Quiz.Remote — Android ubuntu-mac-mini cargo tauri android build; uses the Android SDK + NDK baked into the container.
Quiz.Remote — iOS macos-mac-mini cargo tauri ios build; Xcode-signed; fastlane uploads to TestFlight.
Schema codegen (schemas/) either Linux or Darwin TypeScript + GDScript (schemas/codegen/gen_gdscript.py) drops; published as a pipeline artefact.

Android export gotcha — ETC2/ASTC. A headless Godot Android export fails with an empty error when textures/vram_compression/import_etc2_astc=true is missing from project.godot — the import step never produced the mobile-format textures the export needs. The setting is project-wide and checked in; if the Android job dies with no message, check this first.

Routing rules — single pool, OS demand

Pipelines never reference an agent name. They route by:

pool:
  name: SelfHosted
  demands:
    - Agent.OS -equals Linux       # or Darwin / Windows_NT

Adding tools to an agent never changes the routing — capabilities are auto-reported by the agent on every registration. Image-swap / rollback procedures live in each host's runbook (Mac mini §11.1; Windows C:\ci\STATUS.md "image.txt").

Windows host (REGGIESPC) — distinguishing details

The Windows host's runbook is on the host itself, so the architecturally interesting points are summarised here:

Secrets the agents do not carry

Bake nothing project-specific into the agent images. Per-pipeline secrets live in ADO variable groups:

Godot itself needs no licence or activation secrets — the headless binary imports, tests, and exports unauthenticated.

The PAT that each agent uses for self-registration lives on the relevant build host (Mac mini: /Volumes/External/ci/secrets/pat; Windows host: C:\ci\secrets\pat.txt). It is not an ADO variable group and is not exposed to any pipeline job.

Caching policy

Cache only what's safe across ephemeral runs:

Anything reproducible from sources alone (build outputs, intermediate dist/, *.app/) is not cached.

See also

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
Engine-free GDScript domain — schemas, scoring, protocol state, package format Quiz.Stage/tests/core/ GdUnit4 unit (headless) core/ is plain RefCounted GDScript with no scene dependencies (Separation of Concerns). Tests run under godot --headless — no display, no scene tree. Fast in CI.
core/ engine-free conformance Quiz.Stage/tests/core/test_core_engine_free.gd GdUnit4 unit (headless) Scans every script under core/ and fails on any reference to Node, SceneTree, CanvasItem, or an autoload. The boundary is enforced by this test, not by a compile unit.
Designer services — AuthoringSession, CommandDispatcher, persistence + library + transfer service implementations Quiz.Designer/src/**/*.spec.ts Headless (Jest) Pure TypeScript with mocked PlatformAdapter. Run via pnpm 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 + a11y (runAxe).
Schema cross-fixture parity — shared GDScript / TS fixtures schemas/fixtures/ (consumed by both GdUnit4 and Jest harnesses) Headless Asserts that semantic validation rules implemented on both sides agree on the same inputs.
Shared renderer — object-type scenes, reveal player, slide runner binding Quiz.Stage/tests/render/ GdUnit4 scene test Instantiates the render/ scenes, drives the host_runtime / client_runtime hooks, asserts node state. Runs headless (--headless rendering server) in CI.
App-specific behaviour — Host session flow, Client join + team-play, Preview JS-bridge binding Quiz.Stage/tests/apps/<app>/ GdUnit4 scene test Exercises the entry scenes and app chrome. Per-app folders because behaviour diverges.
App boot-chain smoke Quiz.Stage/tests/apps/test_boot_dispatch.gd GdUnit4 scene test Boots boot.tscn with each --app= override and asserts the matching apps/<app>/<app>_main.tscn loads with zero errors.
Remote services + components (when the Remote scaffolds) Quiz.Remote/src/**/*.spec.ts, Quiz.Remote/e2e/ Headless (Jest / Playwright) Same harness and conventions as the Designer rows above.
Designer Tauri IPC contracts + Rust services Quiz.Designer/src-tauri/tests/ + #[cfg(test)] modules cargo test Pins command envelopes + typed error variants; services are Tauri-runtime-free behind trait seams.

GdUnit4 is vendored under Quiz.Stage/addons/gdUnit4/ at a pinned version. Test the project's code, not the framework.

Conventions

CI gate

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

godot --headless --path Quiz.Stage --import godot --headless --path Quiz.Stage -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests

--ignoreHeadlessMode is required by the pinned GdUnit4 (v6.1.3) — without it the CLI aborts with exit 103 ("Headless mode is not supported"). Our core/ + render/ suites are engine-free and never depend on InputEvent transport, so headless is correct. No display, no licence, no editor automation — the same pinned Godot binary CI uses for exports. - Local gate (Quiz.Stage/tools/gate.ps1) is the per-iteration equivalent of the Designer's pnpm gates and the canonical thing to run before every Stage commit. Five checks, exit code = failure count: headless import, GdUnit4 suite (headless), gdformat --check --line-length=100, gdlint (gdtoolkit 4.x), and a static-typing scan (no untyped var / func return / param). It resolves Godot from $env:GODOT_BIN then C:\Godot\godot.exe; gdlint/gdformat from PATH then the pip user-scripts dir. Pinned tools: Godot 4.5, GdUnit4 v6.1.3 (vendored under addons/gdUnit4/), gdtoolkit 4.x (gdlint 4.5.0). The GdUnit4 reports/ output is git-ignored. - Designer unit + component suites run via pnpm test (Jest + Angular TestBed); the Playwright e2e suite runs against ng serve. - Schema cross-fixture parity suite runs on both sides — GdUnit4 on the Stage side, Jest on the TypeScript side — against shared inputs in schemas/fixtures/. - Designer Rust gates (cargo check / clippy / fmt / test) run per the CLAUDE.md gate list. - Tauri builds (tauri build for the Designer; tauri ios|android build for the Remote) are build-only gates; UI behaviour is exercised by the Angular suites. - A red test fails the pipeline; the lint pipeline is a required check before merge.

Local fast feedback: GdUnit4 runs from the Godot editor's GdUnit4 panel per-suite, or via the same headless CLI as CI; pnpm test / pnpm test:e2e for the Angular workspaces; cargo test for the Designer shell.

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 — Designer language. 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.

Visual verification — Stage surfaces

The embedded slide preview is the Host/Client renderer (same project, same Compatibility renderer — see Designer Shell), so Designer-side preview screenshots double as Stage render verification for slide content. App chrome unique to the Host/Client (lobby, join flow, operator window) is verified by running the relevant app from the project checkout (godot --path Quiz.Stage -- --app=host) and comparing against the matching mockups.

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 Designer / Remote (Tauri + Angular) side is likewise driven entirely through text: pnpm build / pnpm test / Playwright against ng serve, with the screenshot harness providing the visual-verification loop (Testing — Visual verification).

Per-App Scaffolding

The on-disk shape the live-play codebases converge on: the internal layout of the Quiz.Stage/ Godot project (which ships the Host, Client, and Preview from one tree) and the scaffold of the Quiz.Remote/ Tauri + Angular workspace. The Designer is its own Tauri 2 + Angular workspace — see Designer Shell and Repository Layout — Designer project graph.

The top-level placement of these folders in the monorepo is in Repository Layout; this page is the level below — what goes where inside each project and the conventions every chunk follows.

Quiz.Stage/ — internal layout

Quiz.Stage/
├── project.godot               # Compatibility renderer; autoload list; main scene = boot/boot.tscn
├── export_presets.cfg          # host-windows, host-macos, client-android, client-ios, preview-web
├── boot/
│   ├── boot.tscn               # Project main scene — never the app itself
│   └── boot.gd                 # Dispatch: export feature tag (host/client/preview) or --app= override
├── apps/
│   ├── host/                   # host_main.tscn + Host-only scenes/scripts
│   ├── client/                 # client_main.tscn + Client-only scenes/scripts
│   └── preview/                # preview_main.tscn — render target driven by the Designer's JS bridge
├── core/                       # ENGINE-FREE GDScript — see Separation of Concerns
│   ├── generated/              #   codegen output from schemas/ (git-ignored)
│   ├── packaging/              #   .quiz read/write, manifest validation
│   ├── protocol/               #   envelopes, dispatch, session state machine
│   ├── scoring/                #   scoring rules, tiebreakers
│   ├── discovery/              #   mDNS advertise/browse over PacketPeerUDP
│   └── object_types/           #   ObjectType contract + registry
├── render/                     # Shared slide/element renderer — one folder per object type
│   └── <type-id>/              #   <type_id>_host.tscn, <type_id>_client.tscn + root scripts
├── assets/                     # Brand fonts, palette resources, mascot, audio stings
├── addons/
│   └── gdUnit4/                # Test framework (pinned version, vendored)
└── tests/                      # GdUnit4 — unit tests for core/, scene tests for render/ + apps/

One folder = one concern. core/ is the engine-free layer (Separation of Concerns); render/ is the shared element renderer every app instantiates (Object-Type Contract); apps/ holds only what is unique to each app's chrome and flow.

Boot dispatch

boot/boot.tscn is the project's main scene for every preset. boot.gd resolves which app to run and replaces itself with the matching entry scene:

  1. Dev override first — a --app=<host|client|preview> command-line argument (read via OS.get_cmdline_user_args()) wins, so any app launches from one project checkout or from the editor's run configuration.
  2. Export feature tag — each export preset carries a custom feature tag (host, client, or preview); boot.gd checks OS.has_feature(...).
  3. Loads apps/<app>/<app>_main.tscn via change_scene_to_file. No tag and no override is a hard error with a clear message — never a silent default.

Adding a new app surface = new apps/<name>/ entry scene + a preset with the <name> tag + one dispatch-table row, per Repository Layout — Adding a new Stage app surface.

Export presets — per-app identity

Export presets own everything that differs per shipped app; the project itself stays app-neutral.

Preset Feature tag Identity Notes
host-windows, host-macos host Product name Quiz.Host, desktop icons Landscape; targets TV / projector at 1920×1080 design size.
client-android, client-ios client Product name Quiz.Client, mobile bundle ids, camera permission (team photo, F-CL-14) Autorotate — Client supports portrait and landscape on phones and tablets.
preview-web preview Web export, single-threaded Consumed by the Designer as a CI artefact; single-threaded avoids the COOP/COEP header requirement inside the Tauri WebView.

Autoloads

The autoload list is short and deliberate — autoloads are engine-side adapters, never business logic (which lives in core/):

Autoload Script Role
NetLoop apps/_shared/net_loop.gd Pumps every active core/-owned socket object (TCPServer, WebSocketPeer, PacketPeerUDP discovery) once per _process frame. Owns no protocol state.
Session apps/_shared/session.gd The central session controller — holds the core/ session state machine and re-emits its events as signals for UI scenes. UI never polls sockets or mutates networking state directly.

core/ scripts must never reference an autoload — the GdUnit4 conformance test enforces this (Separation of Concerns).

Universal conventions (Stage)

Per-app entry scenes

Host (apps/host/) — the live-quiz runtime: renders the quiz to a TV / projector and runs the in-process WebSocket server.

Client (apps/client/) — the team-device app: joins a session, renders each slide's Client canvas, collects answers.

Preview (apps/preview/) — the Designer's embedded render target. No chrome, no networking.

Quiz.Remote/ — scaffold

The Remote is a second, much smaller Tauri 2 + Angular workspace targeting Tauri mobile (iOS / Android). It mirrors the Designer's shape one-for-one so conventions, lint config, and CI templates transfer verbatim:

Quiz.Remote/
├── src-tauri/                  # Rust shell — mobile config, mDNS browse command, capabilities
├── src/                        # Angular app — pairing flow, mirror view, host-notes, nav controls
│   └── app/generated/          #   codegen'd TS types + ajv validators (git-ignored)
├── e2e/                        # Playwright specs against ng serve
├── angular.json
├── package.json
└── tsconfig.json

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.

Godot for the live-play surfaces (Host, Client, slide preview); Tauri 2 + Angular for the Designer and the Remote. (Adopted June 2026, replacing Unity + C# before any quiz logic had been written in it.) Godot 4.x gives a single engine for live-play visuals (animation, particles, shaders, 2D and 3D) with three properties Unity could not offer this project: text-based scenes and scripts (.tscn / .gd diff cleanly in review and are first-class for AI-driven development — the project's primary authoring mode), honest headless CI (godot --headless imports, tests, and exports without licensing or editor-automation fragility), and small artefacts (~40 MB exports, fast cold start on phones). The Designer is forms-heavy authoring where Angular's CDK primitives are best-in-class for tool UIs; Tauri 2's tiny Rust shell delivers the native windowing/menus/dialogs the desktop surface needs without bundling Chromium. A PlatformAdapter interface keeps the eventual Web Designer Stretch additive. See Tech Stack and Designer Shell.

Host, Client, and the slide preview are one Godot project (Quiz.Stage/), not three. Three entry scenes (apps/host/, apps/client/, apps/preview/) dispatch from a boot script on export feature tags; export presets own per-app identity (name, icons, bundle ids, permissions). The slide/element renderer in render/ and the engine-free logic in core/ are therefore shared by construction — no shared-package machinery, no cross-project asset-id drift (Godot's per-project .uid registry makes sharing scenes between projects fragile; sharing inside one project is free). One import cache, one test suite, one CI build graph. The cost — per-preset export filters to keep mobile/web artefacts lean — is paid in config, not architecture. See Repository Layout.

GDScript only inside Quiz.Stage — no C#. Godot's .NET runtime has no web export, and the Designer's embedded preview is a web export; splitting languages by export target would fork the render core. GDScript is also the better-trodden path for headless tooling and AI authoring. Engine-free logic stays testable through the plain-RefCounted discipline in Separation of Concerns, not through a separate CLR library.

Compatibility (GL) renderer on every Stage preset. The web export supports only Compatibility; running the same renderer on desktop Host and mobile Client means the Designer's embedded preview is pixel-faithful — same engine, same renderer, same scenes. Forward+-only features are off the table for slide content by policy.

Slide preview is the Quiz.Stage web export embedded in the Designer's main WebView. One preview integration that works inside any WebView, no HWND/NSView reparenting. Angular pushes slide state over the in-WebView JS bridge (Godot JavaScriptBridge); the preview emits ready / rendered / error callbacks back. Single-threaded web export avoids the COOP/COEP header requirement inside Tauri's WebView. Because the preview binds the same render/ scenes the Host and Client instantiate, fidelity is effectively 100% — the Run from slide action (F-DE-27) remains for validating the true full-screen Host experience.

The Remote is Tauri 2 + Angular, not a Stage app. The Remote is pure operator UI — calm Studio chrome, host-notes, nav controls, HUD — with no slide rendering of its own. DOM + Angular reuses the Designer's tokens, components, WebSocket client, and a11y infrastructure; Tauri mobile ships it to iOS/Android. Its live mirror of the Host display is a downscaled frame stream pushed by the Host over the control message family — the Remote never re-implements the renderer, so there is no third rendering codebase to keep faithful.

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 TypeScript apps (Designer, Remote) and Quiz.Stage; no shared runtime library across the language boundary. Both sides build against the same JSON Schema definitions in schemas/ — GDScript types + validators regenerate into Quiz.Stage/core/generated/, TypeScript types + ajv validators regenerate into the Angular codebases. Designer business logic (quiz model manipulation, undo / redo, validation, push orchestration) is implemented natively in TypeScript inside Angular services. 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 GDScript for Host runtime enforcement — and both sides assert against shared fixture sets in schemas/fixtures/. This trades a small amount of duplicated semantic logic for hard language-boundary decoupling. See Repository Layout — Cross-language contract.

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 can reliably run an in-process server. The same server handles three message families: Designer transfer, Client live-play, and Remote control. The socket loop lives in a dedicated autoload pumping _process; protocol interpretation stays in engine-free core/ classes. 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 engine-free contract layer (Quiz.Stage/core/object_types/ defines the contract; ships built-ins behind a registry), and the apps (each populates its registry at 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 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 scripts 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. Godot 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. The quiz domain model these requirements hang off — slide kinds, the first-class question node, answer-key custody — is defined in Question Model.

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, the core navigation commands (advance / go-back), and the MVP question-flow cues (Reveal answer + buzzer adjudication, F-RE-10). 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 Godot 4.x's platform support matrix (Host / Client / Preview — the Quiz.Stage export presets), Tauri 2's supported-platform matrix (Designer desktop, Remote mobile), 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 Godot Performance frame-time monitor sampled in a GdUnit4 scene-test harness; 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 the Stage host app'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 the Stage client app; 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.Stage/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 — Designer language.

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.Stage preview web-export 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.question-type-picker dialog New slide — pick a type designer.panel.slidelist.add · designer.panel.slidelist.round.add main-shell (slide scaffolded + selected) · Esc = nothing created MVP F-DE-3, F-DE-7 spec
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 designer.dialog.question-type-picker MVP F-DE-3 static main-shell
designer.panel.slidelist.round.add inline + Add slide to round round header row designer.dialog.question-type-picker (scaffolds into that round) MVP F-DE-3, F-DE-4 spec
designer.panel.slidelist.row inline (slide row) slidelist inline (select) MVP F-DE-3 static main-shell
designer.panel.slidelist.row.badge inline (kind / question-type glyph) slidelist row inline MVP F-DE-3 spec
designer.panel.slidelist.row.invalid inline (blocking invalid badge) slidelist row inline (tooltip: missing / duplicate input binding) MVP F-DE-3 spec
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 (round scoring defaults, per F-DE-9). Same pattern as slide-level / element-level Properties.

+ Add slide opens the question-type picker (designer.dialog.question-type-picker) — never a bare blank slide. Authoring is question-first; see Question Model. The picker is a modal grid of type cards: the 8 MVP question types (Multiple choice · Free text · First letter · Numeric · True / False · Ranking · Eliminator · Buzzer) plus Blank (interstitial) — each card glyph + name + one-line description, keyboard-navigable (arrows / Enter), Esc cancels with nothing created. Picking scaffolds the slide from the type's template: prompt text elements on both canvases template-bound to question.prompt, the input element pre-placed on the client canvas and bound via question.inputBinding, default scoring/timing. Flow detail in Designer flows §3.

Row badges: every slide row carries its kind / question-type glyph so the rail reads as a quiz, not a deck. A question slide with a missing or duplicate input binding renders a blocking badge; Export .quiz refuses while any slide is invalid.

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.Stage preview) 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.media inline MEDIA insert tab inline Alpha F-DE-7, F-DE-8 static main-shell
designer.pane.right.insert.cat.display inline DISPLAY (timer · leaderboard · …) insert tab inline MVP F-DE-7, F-DE-10 spec
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 (question-first slide inspector) 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

Slide Properties are question-first (Question Model): for a question slide the inspector leads with Prompt (canonical question.prompt) · Answer key (per-type editor) · Scoring · Timing, with presentation concerns (canvas background, …) below. Scoring + timing live on the question node, never on the slide. Interstitial slides show a presentation-only inspector. The Designer never authors answer-reveal slides — reveal presentation is built-in per question type, driven from the answer key (custom reveal styling is Beta).

The Insert palette carries content / display elements only (text, image, timer, leaderboard, …). Input elements are not free-dragged to create questions — a question's single input element comes from the question-type scaffold. Any action that would introduce a second input element onto a question slide (palette drag, paste, duplicate) is rejected with the hint "one input per question — change the question type instead"; changing the question type re-scaffolds the input element (confirm if it was customised). Both canvases use the same anchor-based element model — there is no region/stack editor for the client canvas.

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:

Resolved 2026-06-12 (question-first model — see Question Model):

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 content / display object types ship (core.audio-clip, drawing surfaces, etc.) which category they live in. New question types extend the question-type picker, not the palette.

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 — refused while any question slide is invalid (missing / duplicate input binding); the action surfaces the first invalid slide instead
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.

Add slide — question-type picker. Authoring is question-first — see Question Model. + Add slide (slide-list footer) and + Add slide to round (round header) never create a bare blank slide; both open the question-type 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 ADD[+ Add slide /
+ Add slide to round] --> QTP[Question-type picker
8 type cards + Blank] QTP -->|pick question type| SCAF[Slide scaffolded from template
prompt · bound input · defaults] QTP -->|Blank interstitial| INT[Interstitial slide] QTP -->|Esc| MS0[Main shell · nothing created] SCAF --> MS[Main shell · new slide selected] INT --> MS classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class MS,MS0 terminal

The picker is a modal grid of type cards — one per MVP question type (Multiple choice · Free text · First letter · Numeric · True / False · Ranking · Eliminator · Buzzer) plus Blank (interstitial). Each card carries the type glyph, name, and a one-line description. Fully keyboard-navigable: arrow keys move between cards, Enter confirms, Esc cancels (no slide created). Picking a question type scaffolds the slide from the type's template — prompt text elements on both canvases (template-bound to question.prompt), the input element pre-placed on the client canvas and bound via question.inputBinding, and default scoring/timing. The freeform canvas tools then decorate the scaffold; they never create the question.

Question-first editing rules (canonical model: Question Model):

Both canvases — host and client — use the same anchor-based element model (core.rect-transform anchors + pivot + position + size + rotation); there is no separate region/stack editor for the client canvas.

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 Host process] 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 — Host language (operator + audience halves).

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 — Remote language.

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

Question-first slide model

The quiz domain model is question-first — canonical record in Question Model:

Built-in question types + display elements (MVP cohort)

All 8 question types ship in MVP — the earlier 8-vs-4 scope conflict is resolved in Question Model:

Question type Notes
core.multiple-choice Tap to submit one option. Single-answer in MVP; multi-answer + elimination-schedule + further speed-bonus curve values land in Beta.
core.free-text Text entry; answer key holds accepted answers + match mode. Alpha adds the marking-queue review.
core.first-letter Single-pick A–Z grid (3-row default; constrainable to a subset); auto-marked against the key's correct letter. Pairs with core.audio-clip in Alpha to deliver "music questions" without a dedicated type.
core.numeric Numeric entry; key proximity exact / closestAbsolute / closestRelative (essential for tiebreakers and estimation rounds). All modes ship in MVP under one type.
core.true-false Binary ✓/✗ answer.
core.ranking "Sort it out" — drag-to-reorder. Author defines the correct order; credit is all-or-nothing or per-position.
core.eliminator Options vanish on an authored eliminationSchedule or quizmaster cue; teams may answer at any point (earlier scores more via the speed bonus); the final-reveal beat is quizmaster-cued.
core.buzzer First-press-wins across every Client; locks all buzzers + plays the winning team's chosen buzzer jingle on the Host; quizmaster adjudicates Correct / Incorrect. The only non-auto-marked MVP type.

Display elements (not question types): core.text (either canvas — reference implementation of the plugin contract), core.timer (a view of question.timing; valid only on timed question slides), core.leaderboard (Host canvas by default; Client canvas only via the per-quiz Client leaderboard visibility toggle, default off), plus a small set of layout primitives.

The remaining object types (core.image, core.audio-clip, core.video, core.image-reveal, core.mini-game, core.match-pairs, core.word-scramble) are deferred to Alpha or Beta. core.drawing and core.categories are Stretch.

Team customisation at join

Buzzer-in-MVP cascades the team-customisation bundle. The Designer ships the asset plumbing and Client picks team photo + buzzer jingle at join, both rendered on the Host:

Scoreboard visibility

By default the Client renders only its own score and standings (F-CL-8); the full leaderboard belongs to the Host canvas. The quiz manifest carries a per-quiz clientLeaderboardVisible toggle (default false) that opts the team's Client back into seeing other teams' scores. The on-demand "show scoreboard" overlay added in Alpha is Host-only.

Designer behaviours

F-DE-2 to F-DE-14, F-DE-18, F-DE-19, F-DE-20, F-DE-28, F-DE-29, F-DE-30, F-DE-33, F-DE-34: create quiz; question-first slide creation (type picker + per-type scaffold, F-DE-3/F-DE-34); slides, rounds; place / move / configure elements on both canvases; object-type palette listing the MVP cohort; embedded preview; per-question scoring + timing with round defaults (incl. the per-type answer-key editor in the question-first inspector); per-slide host-notes; per-element reveal trigger; save and re-open .quiz files; discover Hosts and push over LAN; canonical .quiz package format; buzzer-jingle + premade-avatar bundling + default-library browser; per-quiz clientLeaderboardVisible toggle.

Out: F-DE-1 (cloud auth), F-DE-15/16 (cloud save/version/trash), F-DE-17 (stylus), F-DE-31 (wager slides — Beta), F-DE-32 (Bingo mode — Beta). All v1-deferred items are listed in Stretch.

Host behaviours

F-HO-3 to F-HO-17, F-HO-20, F-HO-21, F-HO-25, F-HO-26, F-HO-27, F-HO-30, F-HO-31: WebSocket server + Bonjour; receive Designer push; load .quiz from disk (stripping answer keys into the session-side answer table); offline; resolve object-type registry; start session; join screen showing connected teams; stripped eager push on join; advance through slides; receive answers, mark Host-side against the answer key, score, leaderboard; built-in answer reveal on quizmaster cue; buzzer adjudication (Correct / Incorrect); handle disconnect/reconnect; timer-authority and live override; pair with a Remote and stream mirror + host-notes + state; accept advance/go-back + reveal/adjudicate commands from the Remote; dual-window operator view with Host + Client canvas previews + Next / Previous / Show-scoreboard / Reveal-answer + adjudication buttons; team-photo rendering on leaderboard rows + per-team callouts; per-team buzzer jingle playback.

Out: F-HO-1 (auth), F-HO-2 (cloud library), F-HO-24 (rich Remote command set — Alpha), F-HO-28 (on-demand scoreboard — Alpha), F-HO-29 (live answer-correction — Alpha), F-HO-32 (marking queue — Alpha), F-HO-33 (session results record + JSON export — Alpha).

Client behaviours

F-CL-1 to F-CL-9, F-CL-14, F-CL-15, restricted to the MVP question-type cohort. One shared device per team; team enters team name; captain takes a photo + picks a buzzer jingle from the bundled set (team customisation); receive the stripped eager push (no answer keys); render Client canvas; render + submit all 8 question-type input modalities (multiple-choice / free-text / first-letter / numeric / true-false / ranking / eliminator / buzzer) with no local validation — all marking Host-side; show team's own score by default (full leaderboard gated by the per-quiz visibility toggle); reconnect.

Out: drawing input (Stretch), categories input (Stretch), mini-games (Beta).

Remote behaviours (minimum viable controls)

F-RE-1 to F-RE-8, F-RE-10: 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) plus the question-flow cues (Reveal answer, buzzer Correct / Incorrect adjudication); 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, the marking + results workflow (marking queue, session-results export), 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 the free-text, first-letter, and multiple-choice question types (a "music question" is one of those types alongside an audio clip — no dedicated type needed). Inspector configures the play trigger (on slide entry / on quizmaster trigger / after delay).
core.video Builds on core.audio-clip's media-loading work; less common than audio for live quizzes but completes the media set.
core.image-reveal Picture-question reveal: image obscured by a configurable shader filter (pixelate / blur / mosaic / warp) that gradually clears over the question duration. Multiple-choice answer underneath; earlier-correct = more points. Promoted from Beta — pairs naturally with the Alpha image-media work.

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.

core.drawing moved to Stretch — touch-drawing input is no longer a v1 commitment. The core.buzzer question type moved up to MVP with its team-customisation cascade and adjudication buttons.

Remote rich control commands

The Remote app itself ships in MVP with minimum viable controls (F-RE-1 through F-RE-8 plus F-RE-10 — discovery, pairing, mirror, host-notes, live state, advance / go-back, the Reveal-answer cue + buzzer adjudication, 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, answer-key state) and benefits from the operational hardening that lands alongside (crash recovery, soak testing).

On-demand scoreboard

F-HO-28 — the Host operator (and paired Remote) can summon the scoreboard overlay at any moment, independent of the current slide. Distinct from the placed core.leaderboard element: the overlay is Host app chrome, not a slide element, and dismisses back to the slide it interrupted. Host-only by construction — the overlay never streams to Clients, regardless of the per-quiz Client leaderboard visibility toggle.

Marking queue + session results

The Alpha half of the Question Model's marking workflow (MVP ships auto-marking + buzzer adjudication):

Designer additions for Alpha

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

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

Beta also extends the core.multiple-choice question type with three optional inspector-configurable behaviours that default off: a multi-answer mode (multiple correct options must all be selected, partial-credit configurable), an elimination schedule (incorrect options drop on a timeline, reusing the eliminator's eliminationSchedule shape), and a speed-bonus curve extension — additional curve values (easeOut, stepped) on the question's existing question.scoring.speedBonus.curve field (Question Model). The curve extension is new values on the single MVP scoring home, not a separate element-level speed-bonus concept. core.categories has moved to Stretch; core.image-reveal has moved to Alpha.

Beta additionally ships custom-designed answer reveals — author-styled overlays that replace a question type's built-in MVP answer presentation (Question Model).

Scoring modifiers — wager questions

F-DE-31 — the author can flag a question slide as a wager slide. Each team picks a multiplier (or stake) on the Client before the answer is locked; the multiplier scales the points awarded (or deducted). Inspector configures the allowed multiplier set (e.g. , , ) and whether wagers are visible to other teams. Implemented as a slide-kind extension on question slides — a scoring extension on the question node, not a new object type; interstitial slides cannot carry wagers.

Bingo — quiz-wide game mode

F-DE-32 — quiz-level Bingo mode opt-in. When enabled:

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 Godot-native rig under Quiz.Stage/assets/) and reused by the Host, the Client, and the preview canvas embedded in the Designer — shared by construction, since all three are entry scenes of the same project. 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 and Remote chrome (outside the preview canvas) reuse the mascot via an exported still-frame or Lottie/Rive animation derived from the same rig so brand consistency holds across the Angular surfaces.

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 code. A future stretch goal — alongside cloud-backed authoring — is to allow object types bundled inside a .quiz package as additional GDScript behaviours + scenes. That brings real questions: signing/verification of bundled modules, sandboxing their scripts at load time, 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 names (Quiz.Designer, the Quiz.Stage host / client exports, 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.

  7. Eliminator final-reveal trigger (Resolved — Question Model, June 2026). Resolved by the Question Model — mechanics: core.eliminator's answer key carries its own eliminationSchedule: [{ atMs, removeOptionIds }] for the staged drops (or quizmaster cue), and the final-reveal beat is quizmaster-cued — neither reuses the slide's reveal.trigger field.

  8. First-letter input UX (Partially resolved — Question Model, June 2026). Marking + interaction are pinned in the Question Model — mechanics: core.first-letter is a single-pick A–Z grid (3-row layout default), auto-marked against answerKey.correctLetter, with an inspector option to constrain to a subset (e.g. "only A–F valid"). Still open: the visual grid layout details (row arrangement, sizing, "team locked in 'B'" feedback to the Host) — these land with the Client UI chunk.

  9. Wager-slide multiplier UX (Phase-deferred — Beta). F-DE-31 introduces wager slides. Open: how multiplier picks are displayed (visible to other teams Y/N — author-configurable per the requirement, but default?), whether teams pick before or after seeing the question, how the wager interacts with late-submission rules, and how the multiplier surfaces on the Host scoring HUD. Resolved during the Beta wager-slide chunk.

  10. Bingo manifest shape (Phase-deferred — Beta). F-DE-32 introduces quiz-wide Bingo mode. Open: manifest field shape (bingo: { pictureSet: [...], cardSize: 5, winPattern: 'line'|'fullhouse'|'pattern' }), how slide-order randomisation interacts with rounds (do round boundaries lock?), and whether non-Bingo slides can be interleaved with Bingo slides in the same quiz. Resolved during the Beta Bingo chunk.

  11. Quiz Store + Licensing key format (Phase-deferred — Stretch). Quiz Store + Licensing Portal. Open: license-key format + signing scheme (so the Host can validate a cached entitlement offline), credit refund policy, third-party author onboarding flow, store curation gates, revenue share model. Resolved when the Stretch is promoted.

  12. Live answer-correction re-scoring semantics (Open — narrowed). The surface for F-HO-29 is now defined: the Alpha marking queue (Question Model — marking, reveal, results) lists per-team submissions with accept/reject toggles that re-score retroactively via the generalised adjudicate message — editing question.answerKey and re-running the marker are the same operation. Remaining open: whether a retroactive re-score fires the audience-visible "team scored" sting + per-team callout for newly-correct teams or applies a silent score update, and what the Client sees (just the new standing, or a "your answer was accepted on review" toast). Resolved during the Alpha marking-queue chunk.

  13. Designer + Host licensing surfaces during v1 (Open). Licensing is Stretch but the Designer + Host need to leave room for it: a settings pane for credentials, a Host HUD pill for "Live mode — Xh remaining", a manifest licenseGate field. Open: how much of this skeleton ships in MVP/Alpha/Beta so the eventual Stretch landing is additive rather than retro-fitted. Resolved when the v1 chrome chunks land — the gate is "does the Stretch land cleanly without rewiring the chrome?".

  14. Remaining god-file splits (quality-audit follow-ups) (Open — tech debt, next chunk that touches each file). The 2026-06 quality audit fixed the two biggest duplications (floating-tool-window framework, color math) but three files still exceed the 600-line gate and are scheduled for splitting by whichever chunk next touches them: slide-list.component.ts (~1450 — extract a SlideRailDragController for the hand-rolled round-header drag + hit-testing), selection-chrome.component.ts (~860 — extract scale/rotate gesture controller + move geometry helpers into lib/canvas-drag/), canvas-pane.component.ts (~730 — extract a viewport controller for pan/zoom/marquee). Also: ~12 components still violate the external-template>50-lines rule (sweep chunk), and the Jest backfill covered the 6 priority services — the remaining ~40 unspecced files get specs as their surfaces are next touched.

  15. AuthoringSession ephemeral-preview API (Open — design decision). selection-chrome.component.ts writes live scale/rotate previews via raw session.setActiveQuiz at 60 Hz, committing through SetRectTransformCommand on pointerup. Undo semantics are correct but the mechanism is a documented command-bus bypass in a component. Decide: give AuthoringSession an explicit preview-overlay API (the dragOffset overlay pattern CanvasDragService already uses) so live gestures never touch the undoable document. Resolved when the next canvas-gesture chunk lands.

  16. Generated any leak from schema codegen (Open — codegen config). quicktype emits export type Data = any[] | … | { [key: string]: any } for the element data blob (src/app/generated/{canvas,element,slide}.ts), leaking any into every consumer and forcing as unknown as casts (e.g. layout.service.ts). Fix at the codegen level — emit unknown instead — likely alongside the planned Element components refactor that retires objectTypeData.

  17. Rust IPC contract tests use service-surface, not tauri::test::mock_app (Open — testing convention). The src-tauri/tests/* contract suite drives every command's service layer + pins the JSON envelopes, but does not boot mock_app + get_ipc_response (most commands need a window builder that mock_app scaffolding doesn't provide cheaply). Documented in tests/error.rs. Revisit if a regression ever slips through the command-shim layer itself.

  18. Stage mDNS discovery on iOS (Open — decide at the Client chunk). The Stage apps discover/advertise over pure-GDScript multicast (PacketPeerUDP — see Tech Stack), but raw multicast on iOS requires the restricted com.apple.developer.networking.multicast entitlement, which Apple grants case-by-case. The no-approval alternative is a thin native Bonjour plugin (NWBrowser), which keeps discovery first-class without the entitlement. Decide between the entitlement request and the native plugin when the Client chunk lands; QR-code / manual-IP join ships as the universal fallback regardless of the outcome.

  19. Godot CameraServer maturity on Android / iOS (Prototype-deferred). Team-photo capture at join (F-CL-14) plans to use Godot's CameraServer, whose maturity on mobile — especially Android — is unproven. Prototype the capture → crop → downscale → JPEG pipeline early in the Client chunk; if CameraServer falls short on either platform, fall back to a small native camera plugin behind the same capture seam. See Tech Stack.

  20. Buzzer exclusion penalty default (Open). An incorrect buzzer adjudication excludes the team and supports an optional configurable penalty (Question Model — mechanics). Open: the default penalty value (zero / a fixed deduction / a fraction of basePoints), and whether excluded teams see a distinct Client state ("locked out for this question") versus the generic locked-input state. Resolved during the core.buzzer end-to-end chunk.

  21. Results-export JSON schema shape (Phase-deferred — Alpha). The Host's Alpha session-results export (Question Model — marking, reveal, results) writes final standings + per-round + per-question tables as a local JSON file the quizmaster owns. Open: the concrete schema shape — and whether it lives under schemas/ with codegen like every other cross-boundary contract, given it never crosses the wire. Resolved with the Alpha results chunk.

  22. Interstitial slide templates at MVP (Resolved — blank-only for now, June 2026). The new-slide picker ships Blank (interstitial) alongside the 8 question-type templates (Question Model); the interstitial scaffold is an empty canvas pair. Styled starter layouts (title card, round break, halftime) are deferred to a later polish phase — the picker tile grid already has room for them as additional non-question entries when they land.

  23. core.image-reveal question-type contract (Open — blocks the Alpha image-reveal chunk). Image-reveal is the one Alpha question type beyond the locked MVP-8, and its Designer surface is not buildable against the current contract: (a) its defining behaviour — an obscuring shader (pixelate / blur / mosaic / warp) clearing over the question duration — is a Quiz.Stage/render/ runtime that does not exist yet; (b) its shape is an image-reveal display element on the host canvas paired with a multiple-choice input on the client, which violates the Question Model inputBinding.type == questionType invariant the export validator (quiz-validation.ts) enforces; (c) the legacy core.image-reveal block in slide-schema.md predates the answer-key split and is marked for revision. Resolve the contract (host-display + client-input question shape; reveal-curve + filter + intensity inspector fields; answer-key reuse of correctOptionId) before authoring the Designer half — building it now would invent spec rather than implement it.

  24. Tiebreak ranking semantics (Open — confirm with the leaderboard chunk). Quiz.Stage/core/scoring/tiebreak_resolver.gd implements scoring.tiebreak per a documented reading of the Question Model: each correct team already holds its full computed points (base + speed bonus), so there is no point-splitting — tiebreak resolves only leaderboard rank order among equal-points teams (fastest breaks by Host receive time; shared keeps competition-style tied ranks; host-decides flags the tied group for the operator). The spec's "shared = tied teams each take full points" phrasing reads as "no splitting" (already true in our per-team model) rather than an award-pool split. Confirm this is the intended semantics when the core.leaderboard reveal + the host-decides operator UI land; if an award-pool/split notion is actually wanted, the resolver gains a points-adjustment pass.

  25. Stage mDNS wire codec + Bonjour interop (Open — lands with the Host/Client chunk + real-device testing). Quiz.Stage/core/discovery/service_advertisement.gd defines the engine-free advertised payload — the service record (_quiz._tcp) + its mDNS TXT-key (host/sid/port/title/teams/pv) serialise/parse — but the actual DNS-packet wire codec + the PacketPeerUDP multicast socket (224.0.0.251:5353) are app-layer and deferred: the pure-GDScript advertiser must emit real mDNS records for the Designer/Remote mdns-sd (Rust) browsers to discover it, and that interop cannot be validated headless (no Bonjour peer in CI) — it needs the real-device pass already tracked in #18 (iOS multicast entitlement). Open: (a) confirm the TXT key names against the mdns-sd browse field mapping; (b) whether the GDScript side hand-rolls the DNS-SD packet format or a thin native bridge handles advertise/browse on each platform; (c) the UDP port the SRV record points at for the WebSocket server. Resolve when the Host discovery wiring lands.

  26. core.text data-shape divergence (Designer vs canonical/Stage) (Resolved — canonical migration, June 2026). Resolved by migrating the Designer to the canonical shape (option a — single source of truth). The Designer now authors { schemaVersion, text, promptBound, style{ font, sizePx, weight, colour, align, lineHeight, letterSpacingEm } } end-to-end: a single normalising seam (Quiz.Designer/src/app/object-types/core-text/core-text-data.ts — defaults + readCoreText + the font-role CSS/canvas maps) feeds the inspector, the on-canvas element renderer, the rail thumbnail, and the slide scaffold. The inspector was redesigned into Typography + Paragraph groups exposing every canonical field; the legacy { body, fontFamily, fontSize, color } shape and the per-element font-import are gone. Fonts are the strict 3-role design-system enum (display/body/mono) per Design Spec — Typography — the consequence of dropping per-element custom fonts is tracked in #27. A Designer-authored core.text element now parses directly into the Stage CoreText.

  27. Theme-level font packaging (per-element custom fonts dropped from core.text) (Open — decide when packaged-font theming lands). The core.text canonical migration (#26) collapsed the font picker to the 3 design-system roles (display/body/mono), removing the inspector's per-element font-import button. The Designer's FontImportService + FontRegistryService (@font-face install from manifest.fonts) are retained but currently unwired to any element — visible debt, not silent: they exist for the eventual theme-level model where a .quiz packages a custom display/body/mono face that overrides the role tokens quiz-wide (not per element). Open: (a) the manifest/theme field shape that maps a packaged font to a role; (b) whether the override is per-quiz, per-round, or per-slide; (c) Stage-side resolution of a role token to a packaged face at render. Until then the three roles resolve to Bebas Neue / Inter / JetBrains Mono. Resolve when packaged-font theming is scheduled; if it never lands, delete the two services.

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.
Stage The single Godot 4.x project (Quiz.Stage/) that ships the Host, the Client, and the Designer's embedded slide preview as three entry scenes with per-app export presets. See Repository Layout.
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. Every slide is either a question or an interstitial (Question Model); question slides carry a first-class question node (prompt, answer key, scoring, timing, input binding). 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. Uses the same anchor-based element model as the Host canvas (core.rect-transform anchors + pivot), resolved against the device viewport so it 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.Stage web export 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 (Tauri 2 mobile — the shell the Remote already ships with — or Capacitor). Drives the iPad / Android tablet authoring path off the same Quiz.Stage web-export preview the web tool uses. Ships only after the web-based Designer is in place.

iPad / Android tablet / Linux Host

The Host ships first-class desktop only in v1 (Windows + macOS). iPad and Android tablet Host (with AirPlay / Cast as the projector path) and Linux Host are deferred to Stretch. The Godot codebase is engine-portable so the engineering cost is per-platform export presets + input/output validation rather than a rewrite; the deferral is about scoping the MVP/Alpha/Beta playtesting matrix down to the desktop targets first.

When promoted, each platform inherits the existing Host architecture unchanged — the audience window owns the display (HDMI on Windows/macOS/Linux; AirPlay / Cast on iPad / Android tablet), the operator window sits on the operator's device, and the WebSocket / mDNS plumbing already works on every Godot-supported platform (the iOS multicast-entitlement caveat in Open Questions applies to an iPad Host the same way it does to the Client). Open questions specific to mobile Hosts: backgrounding behaviour, OS volume / mute control over jingle playback, screen-keep-alive policy during long sessions.

Quiz Store + Licensing Portal

An online service that sells quiz packs to quizmasters and enforces a demo / live licensing model on the Designer + Host.

Licensing model:

Quiz Store:

Architecture implications (consider during v1):

Open questions: license-key format + signing scheme, credit refund policy, third-party author onboarding, store curation gates, revenue share model. Captured in Open Questions.

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 built-ins across MVP, Alpha, and Beta. The candidates below extend the catalogue with formats distinct enough to warrant their own object-type module but that didn't make the v1 cut. Each would implement the full plugin contract.

Type id Why it earns its own type Typical placement
core.hotspot Tap on image coordinates. "Where on the map is X?" / anatomy / geography. Client canvas.
core.drawing Finger-drawn submission mirrored live to the Host. First object type to introduce a Client→Host live mirror channel. Moved out of v1 since touch-drawing input is not a defining live-quiz format on its own — added back when the live-mirror channel earns its own product justification. Client canvas (with optional Host mirror).
core.categories "Name 5 X" style multi-line free-text entry with per-line scoring. Author flow + scoring rules need more design work before it earns a v1 slot. 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.