Live Quiz Platform
Product Requirements Document
| Status | Draft |
| Scope | Project (cross-cutting) |
| Last updated | 2026-06-14 |
| Source of truth | .claude/context/knowledgebase/ |
Contents
Overview
Overview
Problem statement
Live quizzes are a popular social format, and software platforms aimed at running them already exist. They tend to fall into one of two camps: feature-light tools that miss the TV-game-show production values quizmasters want, or feature-heavy platforms whose complexity makes set-up and operation a chore. We bring the rich, animated, interactive experience without the complexity, packaged as a subscription service quizmasters can rely on for a polished quiz night that still works on the unreliable internet typical of a real venue.
Solution outline
Four connected apps, one shared schema contract — JSON Schema in schemas/ codegen'd to 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:
- Designer (Windows, macOS — iPad / Android tablet authoring is Stretch) — author quizzes as ordered slides grouped into rounds, with each slide carrying a Host canvas, a Client canvas, and per-slide host-notes; save
.quizfiles to disk and push them to a Host over the local network. - Host (Windows, macOS — iPad / Android tablet / Linux Host is Stretch) — receive a
.quizfrom the Designer (or load one from disk), run the live session as a local-network server, render each slide's Host canvas on a connected projector or TV. On Client join, distributes the.quizpackage to the clients so per-slide play has no network round-trips. - Client (iPhone, Android phone, iPad, Android tablet) — each team joins over local Wi-Fi from one shared device and sees each slide's Client canvas, with elements collecting the team's answers and running mini-games as the slide demands.
- Remote (iPhone, Android phone, iPad, Android tablet) — the quizmaster's pocket controller. Pairs with the Host, mirrors the Host display, shows the author's host-notes for the current slide, and sends control messages so the quizmaster can walk the room. MVP ships minimum viable controls (advance / go-back); the rich control commands (trigger reveals, lock/unlock input, extend/skip timer, override scoring, jump-to-slide) land in Alpha.
A slide's elements are instances of object types — pluggable modules that contribute schema, editor surface, runtime behaviour, and protocol extensions. v1 ships a built-in catalogue (text, image, audio, video, multiple-choice / free-text / 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
- Deliver a polished, reliable live quiz experience across the supported tablet, phone, and desktop platforms.
- Support rich, interactive slides via a pluggable object-type model — text and media for the question, varied input modalities for the answer, animated reveals and mini-games where the round demands.
- Allow quiz authors to plan, edit, save, and transfer quizzes between their own devices over a local network. (A cloud-backed account/library system is a future stretch goal.)
- Operate entirely over local Wi-Fi at the venue, with no requirement for internet at any point in v1.
- Keep the system architecture future-proof for additions like 3D content and internet-based play.
v1 ships in four phases — MVP, Alpha, Beta, Production. Beyond v1 is a Stretch backlog. Scope and acceptance criteria for each phase are in Phases.
Users
| Role | Device | Authenticated? | Primary actions |
|---|---|---|---|
| Quiz Author | Designer (Windows, macOS) + Host (Windows, macOS) | 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:
- Host canvas — TV/projector-format, resolved against a fixed virtual 1920×1080 canvas.
- Client canvas — phone/tablet-format, responsive — the same anchor-based element model (
core.rect-transformanchors + pivot) as the Host canvas, resolved against the device viewport so the layout adapts across phone, tablet, portrait, and landscape.
Plus a per-slide host-notes field (free-form text the author writes for the quizmaster) which renders only on the Remote app during play, never on the Host canvas or any Client.
Authors place elements on each canvas — a text block, an image, a multiple-choice input, a timer, a 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:
- Loads
.quizfiles from the local file system. For v1 the user transfers them either by file copy or by accepting an incoming push from the Designer over the local network. Cloud-backed download (sign in, browse a library, download) is a stretch goal. - Validates each loaded package against its built-in object-type registry; refuses packages whose declared object types are missing or version-incompatible.
- Runs an in-process WebSocket server that handles three flows: receiving incoming
.quizpackage transfers from the Designer when idle, running live quiz sessions with Clients, and accepting control connections from the Remote app. - Advertises itself on the local network via Bonjour/mDNS so Designers, Clients, and Remotes can discover it.
- When a Client joins a session, eagerly pushes the slide content and Client-side resources the Client will need for the whole quiz, so per-slide play is round-trip-free. The push is answer-key-stripped — keys are Host-only custody (Question Model).
- Advances through the quiz's slides, rendering each slide's Host canvas at the connected display's resolution (the 1920×1080 virtual canvas scales to fit) and dispatching element behaviours — playing media, running animations, driving "big reveals," showing leaderboards. Per-team identity rendering — team photo on leaderboard rows + team callouts (F-HO-26), and per-team buzzer jingle playback on
core.buzzerfirst-press wins (F-HO-27) — lands in Alpha alongside the relevant object types. - Runs in dual-window operator-view mode (F-HO-25) when more than one display is connected: the audience window owns the projector / TV (clean slide canvas, ships the full Showtime palette per the cross-cutting Design Specification), and a second operator window sits on the operator's laptop / iPad screen with previews of both the Host canvas and a representative Client canvas for the current slide (so the operator can see what teams are seeing without picking up a Client device), the current slide's host-notes, the session HUD (timer, scores, slide index), explicit Next slide / Previous slide / Show scoreboard buttons, and the same nav + control affordances a paired Remote exposes — effectively an on-machine Remote. The operator window's chrome uses the calmer Studio operator-chrome theme — see Design Specification — Host language for tokens, brand-hue ration, and component rules. On a single-display machine the operator surfaces collapse to a summonable overlay.
- Persists session state (current slide pointer, team list, scores, slide-element state where relevant) to disk often enough that a Host crash followed by a relaunch can resume the same session — this is an Alpha-phase v1 requirement, not Stretch.
- Exposes a Show scoreboard control in the operator chrome (F-HO-28, Alpha) that summons a Host-only scoreboard overlay independent of the current slide.
- Hosts a portal pane for the Quiz Store + Licensing Portal when that Stretch lands — browse and purchase premade quizzes, manage credits, activate Live mode for a 24-hour session. Demo mode (≤ 3 Clients,
DEMO MODEwatermark) is the default until a credit is consumed. The Host's settings + HUD chrome reserve room for this stretch surface from MVP so the eventual landing is additive.
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:
- Discovers active hosts on the local Wi-Fi network via Bonjour/mDNS.
- Lets a team enter a team name and join a session. From Alpha, the captain can also take a photo (or pick from the quiz's bundled premade-avatar set) and pick a per-team buzzer jingle (F-CL-14, F-CL-15); both flow to the Host in the join message and drive per-team rendering / audio there.
- Renders the current slide's Client canvas with its responsive layout adapting to the device's form factor, and dispatches per-element behaviours — collecting team input (multiple choice, free text, drawing, gesture), playing inline media, running mini-games where the slide includes one.
- Shows the team's score and standings.
- Persists its team identity locally so it can rejoin a recovered session as the same team without re-entering the team name (introduced alongside crash recovery in Alpha).
Remote remote
Phase: MVP (minimum viable controls); the rich control-command set lands in Alpha.
The quizmaster's pocket controller — a phone (or tablet) the quizmaster carries while walking the room. It 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:
- Discovers active Hosts on the local Wi-Fi network via Bonjour/mDNS.
- Pairs with one Host at a time. Pairing is gated (manual confirm on the Host the first time a given Remote connects, or QR-code pairing — final UX is a build-plan decision).
- Connects to the paired Host over a WebSocket on a control message family distinct from Designer transfer and Client live-play.
- Renders a live preview of what the Host's main display is showing — a downscaled frame stream the Host pushes over the control message family (see Networking); the Remote never re-implements slide rendering.
- Displays the per-slide host-notes the author wrote in the Designer — hints, answer keys, presentation cues — visible only on the Remote.
- Displays live session state — current scores, timer remaining, slide index.
- Sends core navigation commands to the Host: advance and go-back.
- Continues to function if a Client misbehaves; treats Wi-Fi blips like the Client does (reconnect with the same Remote identity).
Remote chrome uses the Studio operator-chrome theme — controls, host-notes pane, status bar all sit on calm surfaces. The audience-mirror region inside renders Showtime at full saturation. See Design Specification — 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.
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
- Tech Stack for engine, library, and tooling choices.
- Networking for the local-Wi-Fi transport and the live-play model.
- Object-Type Architecture for the slide / element / plugin model.
- Decisions for the rationale behind each load-bearing choice.
Status: Draft Last updated: 2026-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-ins — TCPServer + 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 native — AnimationPlayer, 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
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:
- Tauri shell — Rust application that owns the OS window, the custom-drawn titlebar configuration (
decorations: false,titleBarStyle: "Overlay"on macOS,windowEffects: [mica]on Windows 11), the multi-window API (WebviewWindow), and the IPC bridge that exposes Tauri commands to JavaScript. - Tauri plugins — thin Rust-side capabilities the Angular app calls through Tauri commands:
tauri-plugin-dialog— native file open / save dialogs.tauri-plugin-fs+ thezipcrate — read / write.quizarchives.mdns-sd— Bonjour browse + advertise for the Push to Host action.tauri-plugin-process— spawnQuiz.Hostas a subprocess for Run from slide.tauri-plugin-menu— native OS menus (File / Edit / View / Help).tauri-plugin-tray— optional system tray entry.- System WebView — WebView2 on Windows, WKWebView on macOS. Hosts the Angular application and the embedded
Quiz.Stageweb-export canvas. No Chromium is bundled — the shell uses each OS's existing WebView runtime.
Tier 2 — Angular 21 application
The authoring UI itself, written in TypeScript. Shell-specific capabilities (mDNS, OS dialogs, subprocess spawn) are reached through an abstraction layer: PlatformAdapter provides one interface; TauriPlatformAdapter wires it to Tauri commands. v1 ships only the Tauri impl; the abstraction exists so the eventual Web Designer port lands a BrowserPlatformAdapter against browser APIs without rewriting components or services.
Subsystems:
- Angular shell + components — every authoring surface (main shell, slide list, palette, properties inspector, library panel, push-to-Host dialog, settings, titlebar) is an Angular standalone component. Angular Material provides forms / dialogs / inputs; Angular CDK provides drag-drop / overlay / virtual scroll / tree primitives.
- Angular services (DI) — the business-logic layer in TypeScript:
AuthoringSession—activeQuiz,selection,isDirty, exposed as RxJS signals / observables that components subscribe to.CommandDispatcher—execute()+undo()pipeline; every quiz mutation goes through a command for undoability.PersistenceService—.quizsave / load (calls Tauri fs commands on desktop, File System Access API in the browser).LibraryService— device-wide media catalog with SHA-256 content-hash de-duplication.TransferService— Push to Host (mDNS discovery + WebSocket push).PreviewBridge— pushes slide JSON to the embeddedQuiz.Stagepreview canvas; subscribes to ready / rendered / error events.PlatformAdapter— abstracts shell-specific capabilities so the eventual browser-shell port stays additive.- JS bridge — thin TypeScript wrapper around the window-level JS API the preview export exposes (the Godot side registers it via
JavaScriptBridge) for outbound state push, plus the JS callbacks the preview invokes for inbound ready / rendered / error events. In-WebView, in-process — no IPC.
Tier 3 — Contracts + Stage foundation
schemas/— JSON Schema source of truth for the.quizpackage format, the WebSocket message envelopes, and every built-in object type'sobjectTypeDatashape. Codegen produces:- Typed GDScript classes + validators into
Quiz.Stage/core/generated/(viaschemas/codegen/gen_gdscript.py). - TypeScript types +
ajvruntime validators into the Angular workspaces. Quiz.Stage/— the single Godot project that ships the Host, the Client, and this preview.core/holds the engine-free GDScript (schema types, validation, scoring,.quizread/write, protocol + session state machines, snapshot/replay format);render/holds the shared slide / element renderer scenes that must look identical between Host / Client / Preview — and do, by construction, because they are the same scenes. See Repository Layout and Separation of Concerns.
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%.
- One preview integration that works inside any WebView. No HWND / NSView reparenting, no shared-texture compositing, no
--preview-flagged Host build. The same web bundle slots into the eventual Web Designer Stretch without rework. - The export is single-threaded, avoiding the COOP/COEP (SharedArrayBuffer) header requirement inside the Tauri WebView.
- The Run from slide action below remains the way to validate the true full-screen Host experience — display scaling, timing feel, audio routing — not pixel output.
Boot sequence — splash + shell handoff
The Tauri shell opens two windows in sequence at startup: a borderless 720 × 480 splash that pre-warms the heavy work, then the main authoring shell at the user's saved window size. The splash is the only Designer surface that does not respect the Studio chrome theme — it is brand-locked Showtime and renders identically in light and dark mode. The Adobe / JetBrains / Visual Studio pattern.
- Tauri opens the splash
WebviewWindow(no decorations, no resize, always-on-top) and shows it within ~50 ms of process start. The splash WebView loadssplash.htmlfrom the bundled Angular assets. - Splash boot tasks run in parallel inside the Angular workspace: schema codegen check (TS validator hash matches the bundled schemas),
LibraryServiceindex hydration, recent-files index read,PreviewBridgewaits for the bundledQuiz.Stageweb export to reportready, mDNS Rust-side handshake (Designer-discoverable false by default — we're not advertising, just initialising the listener). - Each task reports its stage to the splash UI via a Tauri event; the splash renders
Step N of 5+ a progress bar against the same brand hero in light and dark mode. - When every task resolves, Tauri opens the main shell
WebviewWindowat the user's saved size (or the mockup default of 2752 × 2064 on first launch), focuses it, and closes the splash window. The splash closes with a 120 ms fade — not a separate dialog dismissal. - If any task fails, the splash stays open with a banner ("Couldn't load schema registry — restart, or open without preview") and the user picks restart vs continue. The shell still opens; the failed subsystem surfaces a degraded-mode banner inside the shell.
The splash is not in the main shell's render tree — it is a separate WebViewWindow that closes before the main shell focuses. This keeps the splash from being themed by the shell's theme toggle and lets the splash render at a different resolution from the main authoring window.
See splash.html for the locked Showtime visual.
Multi-window
Tauri's WebviewWindow API; each tool window or palette can be a separate OS-level window, each its own decorated WebView. Real OS chrome, real OS-level focus, real OS-level minimise / maximise / close.
State sync pattern: main-window-owns-state. The primary window holds the AuthoringSession source of truth. Secondary windows (popped-out preview, popped-out properties inspector) are read-mostly views that subscribe to changes via Tauri's event broadcast (emit_all). Edits in secondary windows route through Tauri events back to the primary window's command dispatcher. This keeps the Angular state graph in one place and avoids per-window state-store reconciliation.
The eventual Web Designer Stretch reuses the same pattern via BroadcastChannel between window.open(...) instances.
Editing flow
- User opens a
.quiz(or starts a new one). AngularPersistenceServicereads via Tauri fs + thezipcrate; the generatedajvvalidators verify on load. - User selects a slide. Angular renders an approximate / form-driven view of the slide for fast editing.
- Angular pushes the slide's state (JSON) to the embedded
Quiz.Stagepreview over the JS bridge. The preview renders the slide pixel-faithful — the same scenes the Host will instantiate at quiz time. - User edits a property. Angular updates
AuthoringSessionvia a command (undoable), pushes the delta to the preview. The canvas re-renders. - User clicks Push to Host — Angular
TransferServicecalls Tauri's mDNS browse to list reachable Hosts, then opens a WebSocket and streams the same.quiz-over-WebSocket framing the Host accepts.
Undo / redo granularity
The Designer's CommandDispatcher (an Angular service) is the authoritative source of "one undoable unit". The rules below define what gets bundled into a single command — and therefore what one Ctrl+Z reverses.
| Action | Granularity | Why |
|---|---|---|
| Add element to a canvas | one command per add | Inserting an element is a discrete action with a clean inverse (remove). |
| Move element (drag) | one command per drag gesture | The command captures the start position on mouse-down, the end position on mouse-up, and writes a single transform delta. Intermediate frames are not on the undo stack. |
| Resize / rotate | one command per gesture | Same model as move — start on handle grab, commit on release. |
| Property edit — text fields | one command per "commit" | A commit is a blur, an Enter press, or a 1500 ms idle while focused. Keystrokes during typing do not pollute the undo stack. |
| Property edit — numeric stepper, slider | one command per gesture | Drag the slider = one command; click the stepper = one command per click. |
| Property edit — boolean / enum dropdown | one command per change | Discrete by nature. |
| Reorder slides | one command per reorder | Drag-reorder = one command capturing the from/to indices. |
| Group / ungroup into round | one command per operation | Captures the slide range and the new (or removed) round. |
| Delete element / slide / round | one command | The command serialises the deleted entity for undo() to restore. |
| Paste (single or multi-select) | one command per paste | Captures all inserted ids so a single Undo removes them all. |
| File operations (Open / Save / Save As / Run from slide) | not on the undo stack | These are session boundaries, not authoring actions. |
| Transfer to Host | not on the undo stack | External side-effect; no inverse. |
The undo stack is per-quiz — Open / New replaces the active quiz and clears the stack. The stack has no hard depth cap in v1; memory pressure is acceptable up to thousands of commands on the typical 50-slide quiz.
Redo stack is the inverse — every Undo pushes onto Redo; any new command clears Redo. Standard editor semantics.
The Angular properties-inspector components mediate all property edits via a PropertyDispatcher helper that batches keystrokes and emits commands at commit boundaries — components never call CommandDispatcher.execute() directly for property edits. Direct callers are restricted to gesture-bound interactions (drag handles, palette drag-drop, slide reorder).
Library content-hash de-duplication
The device-wide asset library at platform app-data folder (LibraryService Angular service backed by Tauri's fs plugin) de-duplicates by SHA-256 so a 50 MB image imported into ten quizzes is stored once.
Layout
<app-data>/Quiz.Designer/Library/
├── blobs/
│ └── <first2-of-sha>/<rest-of-sha> # canonical content, one file per unique hash
├── index.json # the catalog
└── index.json.tmp # written + fsynced + renamed atomically
blobs/ is sharded by the first two hex chars of the SHA-256 to avoid one-folder-million-files on Windows. index.json is the only structured file; the blobs are opaque bytes.
index.json shape
{
"schemaVersion": 1,
"assets": [
{
"sha256": "9c1f...",
"byteLength": 5242880,
"mimeType": "image/png",
"kind": "image" | "audio" | "video" | "avatar" | "buzzer",
"displayName": "skyline-paris.png",
"addedAtUtc": "2026-05-13T19:00:00Z",
"lastUsedAtUtc": "2026-05-13T19:00:00Z",
"originalFilenames": ["skyline-paris.png", "paris.png"],
"refCount": 3
}
]
}
| Field | Purpose |
|---|---|
sha256 |
Canonical id; matches the blob path. |
byteLength |
Cached size for the picker UI. |
mimeType |
Sniffed at import; not trusted for security boundaries. |
kind |
Author-facing category — drives which picker the asset shows up in. |
displayName |
The most-recent import filename; surfaces in the picker. |
originalFilenames |
Audit / disambiguation trail. |
refCount |
Number of active quizzes referencing this hash via the bundle-on-save indirection (see below). |
Import algorithm
- User picks a file via the platform-adapter's file picker (Tauri dialog).
- Designer streams the file through a SHA-256 hasher (Web Crypto API, available in the Tauri WebView).
- If
index.assets[].sha256already contains this hash: - Append the imported filename tooriginalFilenamesif absent. - UpdatelastUsedAtUtc. - Do not copy the file again. - Else: copy the bytes to
blobs/<first2>/<rest>via the standard atomic-write protocol (.tmp→ fsync → rename), then add anassets[]entry. - Re-write
index.jsonatomically.
Bundle-on-save / unbundle-on-load
When the author saves a .quiz:
- The Designer walks every element referencing a library asset.
- For each unique
sha256, copy the blob's bytes into the.quizarchive'sresources/<kind>s/<sha256>.<ext>path. - The element's
objectTypeDatastores the asset as a bundled-resource id ("resourceId": "<sha256>"), so the saved package is fully self-contained.
When the author opens a .quiz:
- The Designer reads each bundled resource's bytes.
- For each one, hashes and looks up in the library.
- Match → reuse the library blob; the in-memory model rebinds the resource pointer to the library hash. The bundled bytes in the open
.quizare not re-imported. - No match → import the bundled bytes into the library (atomic write of the blob + index update), then bind.
The round-trip property: a saved-then-opened quiz produces a .quiz byte-identical to the original (modulo timestamps in the manifest), and the library contains every asset the quiz needs at content-hash resolution. The build plan's Bundle-on-save / unbundle-on-load round-trip test asserts this.
Index corruption recovery
If index.json fails to parse on Designer launch (interrupted write, disk corruption), the recovery path is:
- Move
index.jsontoindex.json.broken.<timestamp>. - Walk
blobs/and rebuild a minimal index from on-disk content:{ sha256: <filename>, byteLength: <file size>, mimeType: "application/octet-stream", kind: "unknown", displayName: "<sha256>", addedAtUtc: <file mtime>, lastUsedAtUtc: <file mtime>, originalFilenames: [], refCount: 0 }. - Re-write
index.jsonatomically. - Surface a banner: "Library index rebuilt — some asset names and categories may be missing. Re-import the asset to restore its display name."
The author loses pretty names + categorisations on a rebuild but never loses the bytes — every reference from a .quiz still resolves by hash.
Garbage collection
A blob whose refCount == 0 AND lastUsedAtUtc is older than 60 days is eligible for GC. GC runs as part of the launch-time snapshot pass. The user is not prompted; the bytes go.
refCount is updated on every Save (counts unique hashes referenced by the saved package) and on every Open (increments for assets newly imported by the unbundle path). Cross-quiz counting means a hash used by three open quizzes has refCount == 3 even if the third one was just opened from disk.
Default asset library
The Designer ships with a built-in default library of small, pre-licensed assets the author can drop into a new quiz. The library covers two asset families today:
- Buzzer jingles — a small curated set of generic clips ("ding", "buzz", "horn", "fanfare") authors can pick from when configuring
core.buzzerelement behaviour and team-customisation rules (F-DE-29). - Premade avatars — a starter avatar set authors can hand to teams as a photo-free alternative (F-DE-30).
The library lives inside the Designer install (under the Tauri app bundle's resources/, not in the quiz). When the author picks a clip / avatar from the library and adds it to the active quiz, the Designer copies the file into the in-memory .quiz package's resources/audio/buzzers/ or resources/avatars/ folder. The selection is registered in the manifest's buzzers[] / avatars[] array. The package therefore stays self-contained — a Host that doesn't have the Designer installed still has every byte it needs to play the chosen jingle or render the chosen avatar.
Library contents and licensing (resolved 2026-05-11):
| Family | Count in v1 | Source / licence |
|---|---|---|
| Buzzer jingles | 10 | CC0 / public domain (Freesound CC0 archives). Zero attribution needed; distributable inside any author-shipped .quiz. |
| Premade avatars | 24 | CC0 / public domain (OpenGameArt CC0 archives or commissioned-then-released-CC0). Same distribution terms. |
Library updates ride app releases — no content endpoint, no auth, no extra infrastructure in v1. New clips ship when the Designer ships. The author is expected to bring their own assets for any "this is my quiz personality" moment beyond the starter set.
Run from slide — local Host process spawn
Per F-DE-27, the Designer can launch a local Quiz.Host process on the same machine starting at the currently-selected slide. This is the toolbar ▸ Run CTA (and F5 from start, Shift+F5 from current slide — PowerPoint convention). The Designer:
- Saves the in-memory quiz to a scratch
.quiz(or uses the on-disk path if clean). - Calls the Tauri process plugin:
Command::new("Quiz.Host").args(["--quiz", path, "--start-at-slide", index, "--launched-from-designer"]).spawn(). - The Host opens in operator-window mode (F-HO-25) if multiple displays are connected — audience window on the primary external display, operator window on the laptop. On a single-display machine the operator surfaces are an overlay.
This is local-machine-only. Network push (Bonjour discovery → pick a remote Host) stays under File → Push to Host (F-DE-14).
When the Web Designer Stretch lands, this surface degrades — browsers cannot spawn processes. The web-side analog is to push the in-memory .quiz to a Host that is already running on the LAN (or hand-paired by code) and send a control-envelope command containing openAtSlide: <index>. The wire-level message is the same one the desktop subprocess path uses internally so the Host code only implements it once. See Networking.
Custom titlebar
The mockups under docs/ui-mockups/designer/ merge titlebar + toolbar into a single Rider-style 44 px strip: logomark + wordmark + menus (left), doc title + dirty dot (centre), history + autosave + Run CTA (right).
The titlebar is an Angular component (<app-titlebar>). The Tauri shell sets decorations: false and uses titleBarStyle: "Overlay" on macOS so the OS draws the traffic lights and the Angular component draws everything else; on Windows 11 the shell sets windowEffects: { effects: ["mica"] } so the Mica blur shows through the transparent CSS background. The same component renders inside ng serve (no native chrome behind it) so visual verification against the mockups stays sound.
The mockups show the macOS traffic-light cluster because that's the design-reference platform — the running titlebar adapts at runtime per host OS so users get the conventions they expect.
Window-control convention per platform
Angular detects the host OS via a small PlatformService (sniffs navigator.userAgent — works under both WebView2 / WKWebView and ng serve Chromium). The titlebar branches on the result:
| Host OS | Caption buttons | Where drawn | Padding reserve |
|---|---|---|---|
| macOS | Real traffic-lights (red / amber / green) | OS, inside the WebView region via titleBarStyle: "Overlay" + setTrafficLightPosition |
80 px on the left edge so the brand never sits under them |
| Windows 10/11 | Flat Win-11-style glyph buttons (min ─, max □, close ✕), 46×32 px each | Angular <app-titlebar>, right edge |
None — Angular owns the right edge |
| Linux | Same Win-style glyph buttons | Angular <app-titlebar>, right edge |
None |
The choice of "macOS = OS-drawn, everyone else = Angular-drawn" follows from Tauri 2: titleBarStyle: "Overlay" is mac-only; on Windows / Linux decorations: false removes the native caption buttons entirely, so Angular has to draw them itself. The hover colours follow each platform's convention — Win 11 close-button hover is #E81123; min / max hover is the chrome's neutral --chrome-hover.
Wiring — Tauri desktop shell
Tauri tauri.conf.json declares the window with decorations: false, the platform-specific titlebar-style flags above, and an initial size. The Rust side exposes window.minimize(), window.toggleMaximize(), and window.close() to the Angular side via @tauri-apps/api/window.
The Angular <app-titlebar> defines the window-drag region with -webkit-app-region: drag (WebView2 + WKWebView both honour it; interactive children opt out with no-drag). The per-platform caption-button + padding rules above sit on top of that.
Titlebar content
Logomark SVG + wordmark + File / Edit / View / Help dropdowns (left), document name + dirty dot bound to AuthoringSession (centre), Undo / Redo + autosave badge + Run-from-slide CTA bound to CommandDispatcher + AuthoringSession (right). Menu actions route through the shared MenuActions service. Native OS menus on the menu bar (where applicable on macOS) are wired through tauri-plugin-menu and call the same MenuActions entries.
Theming
The titlebar inherits the Studio chrome tokens from the Angular global styles (data-theme attribute on <html>) — light / dark is a CSS-variable swap, no rebuild. Tauri-side native chrome (traffic-light position, Mica accent) is updated once on theme change via setTrafficLightPosition / Tauri commands.
Dev-iteration loop
ng serve is the dev-iteration host — purely development infrastructure, not a shipped product. Playwright drives Chromium pointing at http://localhost:4200 directly because it cannot drive WebView2 / WKWebView (which is what the Tauri shell hosts) directly.
ng serve(defaulthttp://localhost:4200).python .claude/skills/ui-mockups/scripts/screenshot.py --url http://localhost:4200/writes a PNG to.build/.- Compare against the matching mockup under
docs/ui-mockups/designer/.
Visual divergence from the mockup is a blocker per CLAUDE.md — UI work — visual verification before sign-off. Type-checking and Angular tests are not sufficient.
The Tauri-specific surface (custom titlebar with OS chrome behind it, multi-window, mDNS, subprocess spawn) is verified manually via tauri dev (Tauri shell + Vite dev server). Tauri-only code paths (e.g. native menu bindings) cannot be exercised under ng serve alone.
Repository Layout
How the apps and their shared contracts are arranged on disk in this monorepo. The high-level decision — schemas as the single cross-language contract; 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/
- Entry dispatch. Each export preset carries a custom feature tag (
host,client, orpreview);boot.gdchecksOS.has_feature(...)and loads the matchingapps/<app>/<app>_main.tscn. In the editor / dev runs, a--app=<name>command-line argument overrides, so any app launches from one project checkout. - Per-preset identity. Export presets own the per-app product name, icons, bundle/application ids, and mobile permission manifests. Export filters strip the other apps' scenes from each build where size matters (Client mobile, Preview web).
- Renderer. Every preset runs the Compatibility (GL) renderer — the web export supports nothing else, and running it everywhere makes the Designer preview pixel-faithful to the live Host/Client. This is a project-wide setting; do not switch individual presets to Forward+.
core/is engine-free. PlainRefCountedGDScript, noNode, no scene-tree access, no autoload references — headless-testable and portable. The boundary discipline is documented in Separation of Concerns.- The preview is the renderer.
apps/preview/contains no rendering of its own — it binds the Designer's JS-bridge state pushes onto the samerender/scenes the Host and Client instantiate at quiz time. See Designer Shell for the embed.
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:
- TypeScript generation — produces interfaces +
ajv-compiled validators intoQuiz.Designer/src/app/generated/(and the Remote's workspace when it scaffolds). Generated files are git-ignored; wired into the Angularpre*hooks (pnpm schema:gen). - GDScript generation — a small in-repo generator under
schemas/codegen/produces typed GDScript classes + validation functions intoQuiz.Stage/core/generated/. Generated files are git-ignored; CI regenerates before the Godot import step.
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
- Add the JSON Schema file under the right
schemas/<area>/directory. - Add it to
schemas/codegen.config.jsonwith the desired output names for TypeScript and GDScript. - Run
pnpm schema:gen(Angular side) and the GDScript generator (python schemas/codegen/gen_gdscript.py) — or push and let CI regenerate. - The new types are now consumable on both sides; cross-fixtures live in
schemas/fixtures/if a semantic rule needs paired assertions.
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:
- Create
apps/<name>/<name>_main.tscn+ scripts. - Add an export preset with the
<name>feature tag and per-app identity. - 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/)
- Quiz schemas, DTOs, manifest format, slide / element / canvas data models (
core/generated/, regenerated fromschemas/). - Scoring rules and computation — including late-submission rules, tiebreakers, per-round and per-slide configurations (
core/scoring/). - Session state machine — current slide pointer, team list, scores, per-element state, the rules for legal transitions (
core/protocol/). - Live-play protocol — message envelope schemas, the state machine that interprets them, the dispatch table that routes typed messages to handlers (
core/protocol/). - WebSocket lifecycle — accepting connections (
TCPServer+WebSocketPeerserver handshake), reading/writing frames, reconnect logic, backoff. The socket objects are owned bycore/classes; they are pumped (poll()ed) once per frame by an engine-side autoload (see below) and report progress by emitting signals. - Service-discovery coordination — the pure-GDScript mDNS protocol over
PacketPeerUDPmulticast, advertised-record state, "host discovered" event generation (core/discovery/). - Quiz package format I/O — reading and writing
.quizarchives viaZIPReader/ZIPPacker, manifest validation against the registered object-type catalogue (core/packaging/). - Object-type plugin contract — the
ObjectTypebase class, the registry, version negotiation, and protocol-extension hooks (core/object_types/, see Object-Type Contract). - Crash recovery snapshot / replay — serialisation and deserialisation of session state, validation that a snapshot matches the loaded
.quiz.
What counts as engine concerns (lives in apps/ or render/)
- Rendering — Control-node layouts, Node2D scenes, shaders,
GPUParticles2D. - Audio playback —
AudioStreamPlayer, bus layout, ducking. - Animation —
AnimationPlayer, built-inTween. - Input handling — touch, keyboard, gamepad.
- Scene instantiation,
PackedSceneloading viaload()/preload, resource management. - Node lifecycle binding —
_ready,_process,_exit_tree, autoload registration. - Pumping
core/-owned sockets — the dedicated networking autoload callspoll()on the core networking objects once per_processframe. The autoload is the engine-side adapter; the protocol interpretation it drives is engine-free. - View scaffolding wiring the object-type runtime contracts (
host_runtime/client_runtime) to scenes inrender/.
Why this boundary
- Headless testability. The protocol state machine, scoring rules, and snapshot round-trip run under GdUnit4 with
godot --headless— no display, no scene tree, no scene fixtures. Tests are fast and reliable; CI runs them on any agent with the pinned Godot binary. - Single-loop concurrency. Godot socket I/O is main-loop polling, not background threads:
WebSocketPeer,TCPServer, andPacketPeerUDPare non-blocking and advance only whenpoll()is called, which the networking autoload does once per_processframe. There is no thread to marshal from — events flow as signals emitted during the poll, on the main loop, directly consumable by scene code (call_deferredonly where re-entrancy demands it). Keeping the socket objects incore/keeps the protocol headless-testable; keeping the pump in the autoload keeps the frame cadence an engine concern. - Single source of truth across the Stage apps. Host, Client, and Preview interpret the same schemas and message envelopes the same way because they are literally the same scripts in the same project — the same types, the same validators, the same state machine. The Designer and Remote keep in sync via schema codegen rather than by sharing the code.
- Forces explicit interfaces. Crossing the boundary requires defining a signal, callback, or typed method. That friction is good — it surfaces hidden coupling instead of letting "view code reaches into protocol code" creep in.
- Forward compatibility. If part of the system ever moves server-side (the Stretch cloud-backed authoring path, an internet-relay server), the engine-free logic is portable as-is. v1 doesn't depend on this — it's a free option.
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:
- Instantiate the engine-free objects from
core/(e.g.WebSocketServer,SessionStateMachine,MessageDispatcher) — typically owned by the networking autoload or the app's main scene. - Pump them — call
poll()/tick()once per_processframe where the object needs it. - 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. - 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 inapps/orrender/.
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:
- Bundle-on-save. When the Designer writes a
.quiz, every resource the manifest or any slide references is copied from the library into the archive'sresources/tree. The archive is self-contained — a Host with no Designer installed plays the quiz unchanged. - Unbundle-on-load. When the Designer opens a
.quiz, each archive resource is content-hash-de-duplicated against the local library. New hashes are imported into the library; matching hashes are reused — the archive bytes are discarded once the library has its own copy.
The invariant: save → open round-trip preserves every resource id and bytes match. Round-trip serialisation tests under schemas/fixtures/packages/ assert this on every codegen change.
Resources are written only when the manifest or a slide references them. The Designer's library can grow indefinitely on disk; an unreferenced library asset never enters any .quiz. The Host never imports into a library — its resources/ tree lives only inside the loaded package directory.
Team-customisation resources
Two resources/ sub-buckets exist solely to feed the team join flow at runtime; neither is referenced by slide elements:
resources/audio/buzzers/— short author-supplied audio clips, one per file. Each clip is registered inmanifest.jsonunder the top-levelbuzzers[]array as{ id, name, file, duration_ms }. At join, the Client lists these bynamefor the captain to preview + pick from (F-CL-15). At runtime, the Host plays the picked clip when that team wins a buzzer race (F-HO-27). Clips are copied into the bundle when the author picks from the Designer's default library (F-DE-29) so the package is self-contained — a Host that doesn't have the Designer installed still has everything it needs to play the chosen jingle.resources/avatars/— author-supplied premade avatar images. Each is registered inmanifest.jsonunderavatars[]as{ id, name, file }. The Client offers these as a fallback when the team captain declines the camera permission or doesn't want to use a photo (F-CL-14).
Both arrays are optional. If absent, the Client hides the buzzer picker (Host falls back to a silent buzz tone) and the avatar picker (Client only offers photo capture).
Photos captured by teams at join are not stored inside the .quiz — that is a session-scoped artefact, transmitted over the WebSocket join message and held in the Host's session snapshot only. See Networking.
Designer→Host transfer
The Designer→Host transfer of a .quiz file over local Wi-Fi is described in Networking.
Networking
Local Wi-Fi (primary path, v1)
The Host runs an in-process WebSocket server on its local network interface — 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:
- Designer transfer — receiving
.quizpackage transfers from a Designer. - Client live-play — running live quiz sessions with team Clients.
- Remote control — accepting control connections from the Remote app. MVP carries the minimum viable controls (discovery, pairing, mirror, host-notes, live state, advance/go-back, 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:
- A single client misbehaving must not affect other clients or the Host.
- The Host must survive client disconnects and reconnects gracefully (a reconnecting Client may re-receive the eager push or a delta).
- The Host must tolerate brief Wi-Fi instability and brief backgrounding.
- A team device dying or backgrounding the Client app does not crash the session.
The detailed message envelopes, lifecycle, and reconnection semantics live in Live Play Protocol.
Crash recovery (Alpha onwards)
The Host snapshots session state to disk after every scoring event and every slide advance. State is sufficient to resume: current slide pointer, team list (team_id, team_name, score), per-element state where the element flagged itself "stateful" (e.g. a timer's remaining time at the moment of snapshot, a leaderboard's last reveal index), and the live-play protocol's last sequence number per Client.
If the Host process crashes, the operator relaunches the Host. On launch with a saved session that matches the loaded .quiz, the Host prompts the operator to resume or start fresh. Resume restores the snapshot; the Host re-advertises on Bonjour; Clients reconnect using their persisted team identity (a stable token written to local storage on first join) and re-attach as their original team with their score intact. The reconnecting protocol path is the same one used for Wi-Fi-blip recovery; the only difference is the Host's state was rebuilt from disk rather than from memory.
This is a v1 capability, not a Stretch goal — it lands in the Alpha phase. MVP runs the disconnect/reconnect protocol but does not persist state across a Host restart.
Remote control
The Remote pairs with one Host at a time. Discovery is the same Bonjour advertisement Designers and Clients use. Pairing is gated — manual confirm on the Host the first time a given Remote connects, or QR-code pairing where the Host shows a code that the Remote scans (final UX is a build-plan decision).
Once paired, the Remote opens a control-message-family WebSocket to the Host. Messages flow both ways. The MVP and Alpha cuts of this protocol are:
| Direction | MVP — minimum viable controls | Alpha — rich control commands |
|---|---|---|
| Host → Remote | Periodic mirror of the Host canvas — 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:
- Schema — a JSON Schema definition under
schemas/object-types/<type-id>/that codegen produces as a typed GDScript class inQuiz.Stage/core/generated/for the Stage apps and as a TypeScript interface +ajvvalidator in the Angular Designer. Each type carries a declared schema version and a JSON serialisation contract. - Designer editor surface — an Angular standalone component (with a TypeScript view-model) that lets the author edit an element's properties in the Designer's properties inspector. The pixel-faithful render comes from the Host runtime surface (#3) running in the embedded
Quiz.Stageweb export via the in-WebView JS bridge (see Designer Shell). - Host runtime surface — a scene under
Quiz.Stage/render/<type-id>/whose root script implements thehost_runtimecontract; renders and animates the element on the Host canvas during live play. - Client runtime surface — a scene under
Quiz.Stage/render/<type-id>/whose root script implements theclient_runtimecontract; renders the element on the Client canvas, including any input handling. - Optional protocol extension — typed message envelopes the object type sends or receives between Host and Client (e.g. an answer submission, a buzzer press). Extensions register on the shared message envelope; the core protocol does not need to change to add a new object type.
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:
- Question types name a question — the value of
slide.question.questionType— and double as the element type id of the single bound input element on the client canvas (the validator enforces the match). The question type owns the question's semantics (answer-key shape, marking, reveal); the element owns only how the input looks. - Content / display elements are pure presentation — placeable on canvases of either slide kind (subject to per-type capability rules), with no question semantics.
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 correctLetter — pinned 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:
- Wager slides — F-DE-31, Beta. Per-question scoring-rule extension: each Client picks a multiplier from an author-defined set before locking. Applies on top of any question type above.
- Bingo mode — F-DE-32, Beta. Quiz-manifest opt-in: per-team 5 × 5 picture grid on the Client, slide-order randomisation per session, line / full house / pattern win condition.
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:
- Index 0 is always a
core.rect-transformcomponent carrying position, size, rotation, anchors and pivot. The Designer'swithRectTransformhelper enforces this on every mutation; loaders coerce missing or out-of-order rect-transforms back to index 0. - A primary-type component matches
element.typeand owns the bespoke per-type payload. The Designer's palette / inspector / thumbnail keyed look-ups all route throughelement.typeso the discriminator stays meaningful —typeis 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:
- 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 ownobjectTypeDatacarries presentation config + option identity only — never correctness. - Submission payload schema — the body of the
answerSubmitmessage the client input element emits, riding the type'sProtocolExtensionon the shared envelope.slideIdis the question id on the wire; the Host resolves the element viaquestion.inputBinding(Live Play Protocol). - 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
answerKeyvia theanswerRevealmessage — authors never hand-place duplicate answer text. Custom author-styled reveals are Beta. - 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 viaquestion.inputBinding, and defaultquestion.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:
default_data()is what the Designer inserts on "add new element". The returned data must passvalidateagainst an empty context.validatereturns aValidationResultwitherrors(rejecting) andwarnings(non-blocking). Validation runs on Designer save, Host load, and Client receive.capabilities.accepts_inputis true for exactly the eight question-type input elements (core.multiple-choice,core.free-text,core.first-letter,core.numeric,core.true-false,core.ranking,core.eliminator,core.buzzer) and nothing else. The package validator enforces that anaccepts_inputelement appears only on the client canvas of a question slide — exactly one per question slide, referenced byquestion.inputBinding, its elementtypematchingquestion.questionType; interstitials carry none (Question Model — slide kinds).migrateis the per-type migration step from any olderschema_versionup to the current built-in. Per-type migrations chain —migrate(v3 → vCurrent)may internally callmigrate(v3 → v4)thenv4 → vCurrent. The contract requiresmigrate(data, schema_version)(current version in = same data out) to return the input unchanged.capabilities.is_statefultypes are queried for a state snapshot during crash-recovery snapshotting — see Live Play Protocol — Crash recovery.
DesignerEditor<TData> — Designer surface
Designer side. An Angular standalone component that renders the properties inspector for one element. Authored in TypeScript in the Angular workspace; runs inside the Tauri desktop shell today and stays portable to the eventual Web Designer Stretch because it consumes the PlatformAdapter for shell-specific capabilities.
// Quiz.Designer/src/app/object-types/designer-editor.ts (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
- The app's boot script constructs its
ObjectTypeRegistry. - 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. - 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 fullhost_runtime/client_runtimesurface, and thecapture_state/restore_statepair wherecapabilities.is_statefulis true. A contract gap is a startup failure, not a slide-time surprise. - Once registration is sealed,
registerbecomes a no-op (returnsfalse) — no late registrations.
Package load
- Host (or Client) deserialises
manifest.json. - For each
manifest.objectTypes[]entry, callregistry.negotiate(id, requested_version). If any returnsINCOMPATIBLEorUNKNOWN_TYPEthe package is refused and the user is shown the offending type id. - For each slide, deserialise
objectTypeDatainto the concrete generated data class for its type. The type-specificvalidateruns after deserialisation. - Slides are now in memory and addressable.
Slide enter
- The slide runner instantiates the registered scene for each visible element (
reveal.trigger == onEntryorvisibility == always). Triggered elements are instantiated but kept hidden until their cue fires. on_slide_enteris called on each element's runtime in element-array order (render order — there is nozOrderfield).
Reveal
- For
trigger == afterDelay: the slide runner awaits aSceneTreeTimerforreveal.delayMsand then fires. - For
trigger == onCue: the runner listens on the control message family for acueRevealenvelope keyed byelementId(sent by the quizmaster from the Host operator window or paired Remote). on_revealis called; the runner plays thereveal.animationvia a built-inTweenifanimation != 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:
- Default is stateless. Authors of new built-in types opt in to statefulness explicitly. Duck typing does not enforce this — the registry validates on app startup that every type with
capabilities.is_stateful == trueregisters scenes exposing acapture_state/restore_statepair. - Snapshot size is bounded. Each state snapshot serialises to ≤ 8 KB. Types whose state genuinely exceeds 8 KB (rare; large drawing canvases are the only candidate) must compress or stream — same wire-budget rationale as the team-photo cap.
- No cross-element references. A stateful element's snapshot must be self-contained — it cannot point at another element's state by id. Snapshots are restored in arbitrary order.
- Protocol extensions are stateless on the wire. Stateful behaviour lives entirely behind the runtime contract;
ProtocolExtensionmessages are transient and never persist.
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:
- Author the type's JSON Schema under
schemas/object-types/{type-id}/{type-id}.schema.jsonand add it toschemas/codegen.config.json. - Run
pnpm schema:gen(Angular side) and the GDScript generator (python schemas/codegen/gen_gdscript.py) to produce the data types on both sides. - Implement the
ObjectTypesubclass inQuiz.Stage/core/object_types/core/{type_id}.gd(handwritten — wraps the generated data class). - Add the
objectTypeDataschema to Slide Schema — Per-Type Schemas — this page is the canonical home for the human-readable form. - Add the type to the built-in catalogue in Object-Type Architecture.
- Implement the Designer editor component +
DesignerEditor<TData>descriptor inQuiz.Designer/src/app/object-types/{type-id}/. - Build the
host_runtimescene inQuiz.Stage/render/{type-id}/{type_id}_host.tscn(+ root script). - Build the
client_runtimescene inQuiz.Stage/render/{type-id}/{type_id}_client.tscn(+ root script). - If the type exchanges wire messages, author the protocol message schemas under
schemas/live-play/object-types/{type-id}/and implement theProtocolExtensionsubclass inQuiz.Stage/core/object_types/core/{type_id}_protocol.gd. - If the type is a question type, additionally contribute the four question-level surfaces from the Question-type contract layer: the
answerKeyschema (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. - Register the type in each app's boot script (Stage registry + Angular
ObjectTypeRegistryservice). - 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}/. - 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.
Cross-links
- Question Model — slide kinds, the question node (
answerKey/ scoring / timing /inputBinding), answer-key custody, and the canonical question-type catalogue this contract serves. - Object-Type Architecture — built-in catalogue + plugin-model narrative.
- Slide Schema — per-type
objectTypeDatashapes. - Live Play Protocol — how protocol extensions ride the message envelope.
- Designer Shell — Undo / redo granularity — the command/undo pipeline
DesignerEditorplugs into. - Separation of Concerns — why the contract classes are engine-free and the runtime surfaces are scenes.
- Open Questions #1 — bundle-supplied (third-party) object types, deferred to Stretch.
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:
- Runs on the Client (typically — Host-side mini-games are possible but rare).
- Uses its own dedicated scene inside the Client canvas (the slide's
core.mini-gameelement instantiates it). - Scores teams in real time or at a final whistle.
- Cleans up on slide advance.
Mini-games are distinct from object types in that:
- They have their own scoring loop, not just an answer-submit.
- They typically render fullscreen (taking over the Client canvas) for the duration.
- They have richer protocol traffic with the Host (per-event score deltas, leaderboard updates).
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
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:
- Author the
configschema underschemas/object-types/core.mini-game/<id>/and regenerate (pnpm schema:gen+python schemas/codegen/gen_gdscript.py). - Implement the
MiniGamesubclass inQuiz.Stage/core/mini_games/<id>.gdand the host / client runtime scenes inQuiz.Stage/render/mini_games/<id>/. - Add the
configschema to this page (or a child page once the framework has more than ~3 built-ins). - Add the mini-game id to the build-plan's mini-game cohort list.
- Register in each app's boot script (Host + Client).
- Round-trip tests for the config (GdUnit4 + Jest, shared fixtures).
- End-to-end test exercising a one-slide quiz containing a
core.mini-gameelement 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:
objectTypeId:core.mini-gamemessageNamespace: the mini-game's id (e.g.core.mini-game.tile-tap)kind: mini-game-specific (e.g.tileTap,barTap,eventEnd)
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 } }
} }
Cross-links
- Slide Schema — core.mini-game — the slide-level configuration.
- Object-Type Contract — the broader plugin contract; mini-game contracts mirror it.
- Live Play Protocol — the wire envelope mini-game traffic rides.
- Phases — Beta — when this framework ships.
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, nokind/questionnode, 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.quizfiles 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
- All JSON uses UTF-8, 2-space indent, LF line endings, camelCase field names. The Designer's writer enforces these on save.
- All identifiers (
quizId,slideId,elementId) are UUIDv4 strings unless stated otherwise. The Designer mints them; they are stable across save/load/transfer. - Timestamps are ISO-8601 UTC strings (
2026-05-13T14:32:11Z). - Durations are integer milliseconds unless suffixed
_s(seconds, for human-authored fields). - All coordinates on the Host canvas are floats measured against the fixed
1920 × 1080virtual canvas. The runtime scales to fit the connected display. Client-canvas coordinates use the same rect-transform model resolved against the device viewport (see Canvases). - All sizes / durations / lists not marked
optionalare required; the validator rejects packages with missing required fields. - Each element's type-specific payload lives in its primary component on
components[]— its shape is owned by the object type (see Object-Type Contract) and pinned per type below.
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):
- Order —
manifest.slideOrder, the only ordering authority. - Membership —
slide.roundId, the only membership authority. manifest.rounds[]— metadata only. NoslideIds, noslideRange.
"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— resolved against the fixed1920 × 1080virtual canvas; the runtime letterbox-scales to the connected display.clientCanvas— resolved against the actual device viewport; anchors (0..1 fractions) carry the responsive layout. The Designer previews against selectable reference resolutions.
"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:
- A
questionslide has exactly one input element, on the client canvas, referenced byquestion.inputBinding, with elementtypeequal toquestion.questionType(same id — acore.multiple-choicequestion binds acore.multiple-choiceinput element). - An
interstitialslide has zero input elements on either canvas.
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).
components[0]is alwayscore.rect-transform— position, size, rotation, anchors, pivot.- Exactly one primary component has
componentType == element.typeand carries the type's presentation-only payload (shapes pinned in Per-Type Schemas).
{
"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:
- Package schema version —
manifest.schemaVersionandslide.schemaVersion. Currently1for both. A bump signals a structural change to the manifest or slide shape. The Host refuses unknown future versions; the Designer migrates older versions on load (one migration step per version bump, implemented in TypeScript on the Designer side and mirrored inQuiz.Stage/core/packaging/for Host-side load, both asserted against shared fixtures inschemas/fixtures/). - Object-type schema versions — per-type, declared in
manifest.objectTypes[].schemaVersionand stamped per-element inelement.typeVersion. Version-negotiation rules live in Object-Type Contract — Version negotiation.
A .quiz is rejected when:
manifest.schemaVersionis unknown to the app.- Any slide file's
schemaVersionis unknown to the app. - A
questionslide is missing itsquestionnode, or aninterstitialslide carries one. - A
questionslide does not have exactly one input element on the client canvas,inputBindingdoes not resolve to it, or the input element'stypedoes not matchquestionType. - An
interstitialslide carries any input element on either canvas. - An
answerKeyis missing (non-buzzer types), malformed for itsquestionType, or references unknown option/item ids on the bound input element. - A round's slides are not contiguous in
manifest.slideOrder, or anyslide.roundIddoes not resolve to amanifest.rounds[].id. - Any element's
components[0]is notcore.rect-transform, or the element lacks exactly one primary component matching itstype. - Any declared object-type id is unknown to the app's built-in registry.
- Any declared object-type version is incompatible with the app's built-in version (per Object-Type Contract).
- Any required field is absent or out of range.
A .quiz is accepted with a warning when:
- A
core.timerelement sits on an interstitial or on a question withdurationMs == 0(untimed — nothing to count). - An option label exceeds 60 chars (renders but may wrap awkwardly).
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.)
Cross-links
- Question Model — the decision record this page renders field-by-field.
- Quiz Package Format — how these JSON files are bundled inside the
.quizarchive. - Object-Type Contract — the contract every object type implements; owns each primary component's shape per type.
- Live Play Protocol — how the slide schema crosses the wire at runtime (including the key-stripped eager push,
answerReveal,adjudicate). - Object-Type Architecture — built-in catalogue + plugin-model overview.
- Design Specification — Typography — the font tokens
core.textreferences. - Open Questions #2 — the question this page resolves.
Live Play Protocol
The wire-level protocol the Host runs with connected Clients and the paired Remote during a live quiz session. Covers the message envelope, the three message families (Designer transfer family lives in Transfer Protocol; live-play + control family are here), timer authority and tick cadence, override semantics, reconnection, and crash-recovery snapshot format.
This page pins what the Networking narrative refers to as the "(planned) Live Play Protocol document". The transport (WebSocket over local Wi-Fi; 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
- Frames are text WebSocket frames containing UTF-8 JSON. No binary frames in live play. (Binary frames are used by Transfer Protocol only.)
- JSON uses camelCase field names. Compact serialisation (no extra whitespace) on the wire.
- All envelope ids and message ids are UUIDv4 strings.
- Timestamps are integer milliseconds since the Host's session start (
sessionElapsedMs). Wall-clock UTC is included only on the initialsessionHellofor log-correlation. - All durations are integer milliseconds unless suffixed.
Envelope
Every frame in the live-play and control families carries this envelope:
{
"v": 1,
"family": "live-play",
"id": "msg-...",
"ts": 12345,
"type": "answerSubmit",
"payload": { ... }
}
| Field | Type | Required | Description |
|---|---|---|---|
v |
integer | ✓ | Protocol version. Current: 1. A bump is a structural change to the envelope itself; the inner type-keyed payloads bump independently via their own payloadVersion where they have one. |
family |
enum | ✓ | live-play or control. The Host routes by family. Single connection per family per peer. |
id |
UUIDv4 | ✓ | Per-frame id. Used for correlation, ack, and replay. |
ts |
integer | ✓ | Sender's sessionElapsedMs when the frame was queued. Clock-skew tolerant — used for ordering, not authority. |
type |
string | ✓ | Message type discriminator. See per-family catalogues below. |
payload |
object | ✓ | Type-specific payload. May be {} for ping-style messages. |
Out-of-spec frames (missing field, unknown type within a known family, v > 1) are dropped and a protocolError envelope is sent back identifying the offending id.
Connection lifecycle
The path discriminates the family: /live-play for team Clients, /control for the Remote.
sessionHello
{ "type": "sessionHello", "payload": {
"sessionId": "ses-...",
"quizId": "quiz-...",
"schemaVersion": 1,
"protocolVersion": 1,
"sessionStartedAtUtc": "2026-05-13T19:00:00Z",
"sessionElapsedMs": 0,
"hostBuild": "0.4.2"
} }
hello (peer → host)
{ "type": "hello", "payload": {
"peerKind": "client",
"clientToken": "ct-...",
"teamName": "The Quizotics",
"appBuild": "0.4.2",
"capabilities": { "audioPlayback": true, "cameraCapture": true }
} }
peerKind:clientorremote.clientToken/remoteToken: device-stable token persisted to local storage on first join. Empty on first-ever connect (Host mints one and returns it inhelloAck).
helloAck
{ "type": "helloAck", "payload": {
"peerId": "peer-...",
"teamId": "team-...",
"issuedClientToken": "ct-...",
"resumeSeq": 0
} }
resumeSeq is 0 on fresh join; on reattach, it is the highest sequence the Host knows it has delivered to this peer, so the Client knows what to expect on snapshot replay.
Team lifecycle
- Name collisions are rejected. A
hellowhoseteamNamematches an already-registered team's name is refused with aprotocolErrorcode: nameTaken; the Client returns to the join screen and re-prompts. (Reattach with a knownclientTokenis not a collision — the team is the same team.) - Max teams is Host-configurable, default 20 (protocol ceiling: 200 per Non-Functional Requirements). A join beyond the configured cap is refused with a
protocolErrorcode: validationFailedand detail"session full". - Mid-question late join. A Client joining mid-question receives the eager push and lands on the current slide (jump-to-current, per Networking) — but it cannot submit for a question that has already locked: its
answerSubmitis refused withprotocolErrorcode: inputLocked, exactly like any other post-lock submission. - Team removal (Alpha). The quizmaster can remove a team via the control-family
removeTeamcommand ({ "teamId": "team-..." }, also raisable from the Host operator window). The Host emits ateamRosterUpdateto all peers, closes the removed Client's connection, and invalidates itsclientTokenso it cannot silently reattach.
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. |
eagerPushBegin → eagerPushChunk → eagerPushEnd (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 (answerSubmit → scoreUpdate) — 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:
- The Client opens a fresh WebSocket to
/live-play. - The Host issues
sessionHellowith the samesessionId. - The Client sends
hellowithclientTokenset andresumeFromSeqset to the last known sequence. - The Host issues
helloAckwith the originalteamId, then replays asessionSnapshot(below) — the current quiz state — followed by any messages withseq > resumeFromSeq. - The Client may render a re-sync banner during the snapshot replay.
A Remote reconnect is identical, with remoteToken instead of clientToken. The Remote re-receives the mirror stream and host-notes for the current slide.
If the clientToken is unknown (Host snapshot was wiped, or this is a fresh device), the Host responds with helloAck { resumeSeq: 0, teamId: null } and the Client falls back to the join screen — same path as first-ever join.
sessionSnapshot
{ "type": "sessionSnapshot", "payload": {
"sessionElapsedMs": 47200,
"currentSlideId": "sl-...",
"currentSlideIndex": 7,
"slideEnteredAtMs": 42000,
"timer": { "mode": "countdown", "remainingMs": 15000, "isLocked": false },
"scoresByTeamId": { "team-...": 7, "team-...": 4 },
"revealedElementIds": ["el-...", "el-..."],
"lastDeliveredSeq": 482
} }
The snapshot is strictly idempotent — the Client must replay over the snapshot and converge to identical state regardless of when it arrives or how many times it arrives. Object-type-specific state (timer remaining-as-of, leaderboard reveal index) lives in revealedElementIds + per-element state queries via the 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.").
Cross-links
- Question Model — the canonical question-first domain model this protocol serves (question identity, answer-key custody, question-type catalogue, marking/reveal/results).
- Networking — transport, gating, the three families.
- Object-Type Contract —
ProtocolExtensionand howobjectMessageenvelopes are routed. - Slide Schema — payload field shapes the protocol references (
reveal,timing,objectTypeData). - Transfer Protocol — the Designer → Host package transfer family (binary frames, distinct from this page's text frames).
- Open Questions #7 — Designer→Host wire-level details (resolved 2026-05-11; live in Transfer Protocol).
- Open Questions #12 — team-photo size + transmit budget (resolved 2026-05-11; lives in the team-join section of Networking).
Transfer Protocol
The wire-level protocol for moving a .quiz package from the Designer to the Host over local Wi-Fi. Separate from the live-play and control families (see Live Play Protocol) — one transport (WebSocket), distinct family, distinct framing.
This page pins what Networking — Designer→Host package transfer refers to as the Transfer Protocol document. Defaults — 64 KB chunks, CRC32 per chunk, resume-from-offset, no extra wire compression — sit on the framing schema below.
Family
The Designer opens a WebSocket to /transfer on the Host. Two frame kinds:
- Text frames — JSON envelopes carrying control messages (
transferOffer,transferAccept,transferReject,chunkAck,transferComplete,transferError). - Binary frames — raw chunk payloads. The accompanying metadata (sequence, CRC32) is carried in the opcode-prefixed binary header below, not in a separate text frame, so the binary stream is self-describing.
This is the only family that uses binary frames. Live-play + control are pure text JSON.
Binary chunk frame
Each binary WebSocket frame on /transfer is:
+--------+--------------+--------+----------------+
| 1 byte | 4 bytes (BE) | 4 bytes (BE) | N bytes
+--------+--------------+--------+----------------+
| op=0x01| seq | crc32 | chunkBytes
+--------+--------------+--------+----------------+
| Field | Size | Description |
|---|---|---|
op |
1 byte | 0x01 = chunk. Reserved: 0x02+ for future binary control. Receiver rejects unknown opcodes with transferError { code: unknownOpcode }. |
seq |
4 bytes, big-endian uint32 | Monotonic, starting at 0, contiguous. The sender retransmits a missing seq with the same seq number on reconnect. |
crc32 |
4 bytes, big-endian uint32 | CRC32 of chunkBytes only (does not include header). Standard polynomial 0xEDB88320. |
chunkBytes |
up to 64 KB | The chunk payload. Final chunk may be smaller. |
Receiver behaviour:
- Verify
crc32ofchunkBytes. On mismatch: emitchunkAck { seq, status: "crcFail" }; sender retransmits. - Verify
seq == expectedSeq. On future seq: queue, emitchunkAck { seq, status: "outOfOrder", expectedSeq }. On past seq (already-acked): emitchunkAck { seq, status: "duplicate" }and drop. - On success: append to receiver's offset file, emit
chunkAck { seq, status: "ok", bytesSoFar }.
Text envelopes
Same envelope shape as Live Play Protocol — Envelope but with family: "transfer":
{ "v": 1, "family": "transfer", "id": "msg-...", "ts": 12, "type": "transferOffer", "payload": { ... } }
transferOffer (Designer → Host)
{ "type": "transferOffer", "payload": {
"quizId": "quiz-...",
"title": "My Live Quiz",
"totalBytes": 52428800,
"manifestDigestSha256": "...",
"packageDigestSha256": "...",
"designerLabel": "Adam's Mac (Designer 0.4.2)",
"schemaVersion": 1,
"declaredObjectTypes": [
{ "id": "core.text", "schemaVersion": 1 },
{ "id": "core.multiple-choice", "schemaVersion": 1 }
],
"resumeSupported": true
} }
The Host shows the operator-confirm prompt — "Adam's Mac wants to send 'My Live Quiz' (50 MB). Accept?" — and pre-resolves the declared object types against its built-in registry. If any are missing or version-incompatible, the Host can pre-reject with an actionable message without accepting the transfer.
transferAccept (Host → Designer)
{ "type": "transferAccept", "payload": {
"transferId": "tx-...",
"chunkBytes": 65536,
"resumeFromOffsetBytes": 0,
"resumeFromSeq": 0
} }
resumeFromOffsetBytes / resumeFromSeq are non-zero only on a resume — see Resume.
transferReject (Host → Designer)
{ "type": "transferReject", "payload": {
"reason": "operatorDeclined" | "unknownObjectType" | "versionMismatch" | "schemaUnsupported" | "tooLarge" | "diskFull" | "internalError",
"detail": "Object type core.unknown-thing version 1 is not built into this Host build (1.2.3)."
} }
The Designer surfaces detail verbatim in its push-to-Host dialog.
chunkAck (Host → Designer, one per chunk)
{ "type": "chunkAck", "payload": {
"seq": 42,
"status": "ok" | "crcFail" | "outOfOrder" | "duplicate",
"bytesSoFar": 2752512,
"expectedSeq": 43
} }
The Designer streams chunks flow-controlled by acks — sliding window of 8 chunks (default), bumpable by Host setting chunkAck.payload.windowHint. This keeps the receiver from backing up on slow disk writes without going single-chunk-blocking.
transferComplete (Host → Designer)
{ "type": "transferComplete", "payload": {
"transferId": "tx-...",
"bytesReceived": 52428800,
"packageDigestSha256": "...",
"verifiedAtUtc": "2026-05-13T19:42:18Z",
"loadedIntoLibrary": true
} }
packageDigestSha256 is the SHA-256 the Host computed over the full received bytes; the Designer cross-checks against transferOffer.packageDigestSha256 and surfaces a transfer-failed error if they differ.
loadedIntoLibrary == true means the Host has saved the .quiz into its local library and is ready to start a session from it. false means the Host received the bytes but the manifest validation failed downstream — the Designer surfaces the validation error.
transferError (either direction)
{ "type": "transferError", "payload": {
"code": "crcFailExhausted" | "unknownOpcode" | "diskFull" | "timeout" | "schemaInvalid" | "internalError",
"detail": "Chunk 42 failed CRC 3 times; aborting."
} }
crcFailExhausted: a chunk has failed CRC the configured retry count (default 3) in a row. The sender aborts and the transfer is abandoned; partial state on disk is left for resume.
Resume
On Designer reconnect (Wi-Fi blip, app crash, deliberate retry):
- Designer re-opens the WebSocket and emits
transferOfferwith the samequizId+packageDigestSha256. - Host looks up its on-disk transfer state for this
(quizId, packageDigestSha256)pair. If found and the package digest matches, the Host responds withtransferAcceptsettingresumeFromOffsetBytesto the byte position one past the last successfully-acked chunk andresumeFromSeqtolastAckedSeq + 1. - Designer seeks to that offset and resumes chunk streaming from the next
seq.
If the package digest in the new offer differs from the stored state, the Host treats it as a fresh transfer (the package has been edited since last attempt) and starts from seq: 0. The stale partial is GC'd.
Partial-transfer retention: 24 hours since last byte received. Older are GC'd on Host launch. Hard cap of 10 partial transfers on disk — oldest is evicted when an 11th arrives.
Validation and rejection
The Host performs three checks before transferComplete:
- Stream integrity — per-chunk CRC32 (handled inline).
- Whole-payload integrity — SHA-256 over the assembled bytes, cross-checked against
transferOffer.packageDigestSha256. - Manifest validation — once the file is on disk, the Host unzips, parses
manifest.json+ everyslides/*.json, and runs the generated schema validators fromQuiz.Stage/core/generated/. On any validation error: emittransferComplete { loadedIntoLibrary: false }followed by atransferError { code: schemaInvalid, detail: ... }.
Object-type resolution is pre-checked in transferOffer (declared list) and re-validated against the final manifest after step 2 — the Designer's declared list and the package's manifest should match, but the post-receive re-check is the authoritative gate. A mismatch surfaces as schemaInvalid with a specific message ("manifest declares 4 types; offer declared 3").
Per-chunk progress UI
The Designer's push dialog renders a progress bar driven by bytesSoFar / totalBytes from chunkAck. The Host's accept screen shows the same bar (operator's view of the incoming push). Both refresh on every ack (≈ 15 Hz at full LAN throughput; less on slow links).
Wire compression
None. .quiz files are zip archives; double-compressing wastes CPU on both ends with negligible size benefit. The WebSocket permessage-deflate extension is negotiated off by the Host for /transfer connections.
Liveness
ping / pong runs at 30 s intervals on /transfer (less aggressive than live-play because the heavy traffic is the binary stream — successful chunk acks are themselves liveness). Idle window before drop: 120 s.
Defaults summary
| Setting | Default | Source |
|---|---|---|
| Chunk size | 64 KB | This page |
| Send window | 8 chunks | This page (tunable per-Host) |
| Per-chunk CRC retry budget | 3 | This page |
| Resume retention | 24 h / 10 partials | This page |
| Liveness ping | 30 s | This page |
| Idle-drop | 120 s | This page |
| Wire compression | off | This page |
| Max package size | 200 MB | Package Format |
Versioning
Same rules as Live Play Protocol — Versioning. The envelope's v field is the family-shared protocol version; binary-chunk framing has its own opcode (op: 0x01) which is itself a version-extension surface (new opcodes = additive; existing opcode framing is frozen).
Cross-links
- Networking — Designer→Host package transfer — the narrative this page is the schema for.
- Live Play Protocol — sibling protocol, distinct family.
- Slide Schema — what's inside
manifest.jsonandslides/*.jsonthat the Host validates after receive. - Object-Type Contract — Version negotiation — the rules behind
transferRejectreason: versionMismatch. - Open Questions #7, #8 — resolutions this page implements.
Authentication
v1: No authentication on any app. The Designer, Host, and Client all run unauthenticated. Designer→Host transfer is gated by being on the same local network (and the Host accepting the incoming connection through its UI), not by credentials. See Networking for the transfer model.
Stretch goal (alongside cloud-backed authoring):
- Author / Host: cloud account sign-in with email magic link as the universal path, plus Sign in with Apple (required when distributed via the iOS App Store), Google, and optionally Microsoft. The same account is used on both apps. Auth provider (managed Postgres + auth-as-a-service / cloud-native identity / similar) decided when this stretch is promoted. The data the Author authenticates against is laid out in Backend Schema.
- Client: Still no authentication — each team enters a team name on joining a session (one shared Client device per team). Team names are ephemeral and not stored beyond the session.
Backend Schema — stretch goal, not v1
Cloud-backed authoring (account, library, Designer→cloud upload, Host→cloud download) is in Stretch. v1 has no cloud backend; the Designer saves .quiz files to disk and transfers them to the Host over the local network — see Networking. The schema below sketches the entities so the v1 architecture stays compatible with the eventual cloud path. The vendor (managed Postgres + S3-compatible object storage) is decided when the stretch is promoted; the SQL shape below is illustrative, not vendor-specific.
profiles
id uuid PK references the auth provider's user id
display_name text
created_at timestamptz
quizzes
id uuid PK
owner_id uuid FK → auth user id
title text
description text
tags text[]
current_version_id uuid FK → quiz_versions
created_at timestamptz
updated_at timestamptz
deleted_at timestamptz -- soft delete
quiz_versions
id uuid PK
quiz_id uuid FK → quizzes
version_number int
storage_path text -- path in object storage
size_bytes bigint
notes text
pinned boolean default false -- protect from version pruning
created_at timestamptz
Storage layout: quizzes/{owner_id}/{quiz_id}/v{n}.quiz
Tenant isolation: users can SELECT/INSERT/UPDATE/DELETE only rows where owner_id matches their authenticated identity. Object-storage paths are similarly scoped to the user's own prefix. Whether this is enforced via row-level security at the database or via the application layer depends on the chosen vendor, decided when this stretch is promoted.
Version retention: the latest 20 versions per quiz are kept, plus any versions marked pinned = true. Older versions are pruned by a scheduled job. Soft-deleted quizzes are recoverable from a "trash" view for 30 days.
Auth flows on top of this schema are covered in Authentication.
CI / CD
The project uses Azure DevOps Pipelines for continuous integration and deployment. Source lives in Azure Repos; pipelines are defined in YAML in this repo and run 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.
ios-signing—MATCH_GIT_URL,MATCH_PASSWORD,ASC_KEY_ID,ASC_ISSUER_ID,ASC_KEY_P8. Consumed bytemplates/fastlane-ios.yml(Stage Client iOS + Remote iOS).android-signing—ANDROID_KEYSTORE_BASE64,ANDROID_KEYSTORE_ALIAS,ANDROID_KEYSTORE_PASSWORD,ANDROID_KEY_PASSWORD. Consumed bytemplates/android-sign.yml(Stage Client Android + Remote Android).- (future)
tauri-signing— Tauri updater key + Windows / macOS code-signing cert pairs, when those tracks come online.
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:
quiz-designer-lint.ymlruns on every PR touchingQuiz.Designer/**.quiz-stage.ymlruns on push tomastertouchingQuiz.Stage/**orschemas/**.quiz-designer.yml/quiz-remote.ymlrun on push tomastertouching their app path orschemas/**(the Designer additionally re-triggers when a newpreview-webartefact version is consumed).- Tagged releases (
v*) run the full matrix across every per-app pipeline.
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)
- Stage exports — no build scripts.
templates/godot-setup.ymlrestores the pinned editor + export templates,godot --headless --path Quiz.Stage --importwarms the import cache, thengodot --headless --export-release <preset> <output>produces each artefact. Schema codegen (python schemas/codegen/gen_gdscript.py) runs before the import step socore/generated/is present. - Tauri + Angular — agent images carry Node 22 + Rust + Tauri CLI + Angular CLI 21 globally. Builds use the project's own deps (
pnpm install --frozen-lockfile→pnpm --filter ./Quiz.Designer build→cargo tauri build); the Designer build copies the latestpreview-webpipeline artefact intosrc/assets/preview/first. - iOS export — Godot / Tauri produce an Xcode project;
fastlane(templatefastlane-ios.yml) builds, signs (viamatch), and uploads to TestFlight using the App Store Connect API. - Android signing — keystore is restored from the
android-signingvariable group into a temp file; the Godot export preset / Tauri build signs + zipaligns the apk/aab in place.
Branching + PR policy
- Trunk-based;
masteris always green. - Feature work lands directly on
master(sequential chunks, see Project management). quiz-designer-lint.ymlis a required status check on PRs.- Per-app build pipelines run post-merge; a failure on
masterblocks the next chunk until reverted or fixed.
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:
- Mac mini:
.claude/context/build-agents.md. - Windows host:
C:\ci\STATUS.mdon the Windows host itself (REGGIESPC, LAN192.168.50.31). Not in the repo — it's a host-local file.
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:
- Data root:
C:\ci\(notD:\ci\— the box has no D drive). Layout mirrors the Mac mini's/Volumes/External/ci/(config/,secrets/,scripts/,logs/,golden/,vms/). - Loop service:
CI-WinAgentLoop(NSSM wrapper aroundwin-agent-loop.ps1, LocalSystem, auto-start). Re-readsconfig/*.txtandsecrets/pat.txtevery cycle, so PAT rotation + image swap take effect with no service restart. - vSwitch:
CI-Switch— Hyper-V External switch on the host's first Up NIC. Clones get LAN IP + internet (needed for Azure agent registration + Godot / Tauri downloads). Creating it caused a one-time NIC re-bind blip; past. - Per-clone resources: 12 GB RAM, 6 vCPU, Gen2 VM with vTPM. Adjustable via
config\vm-memory-bytes.txtandconfig\vm-cpu-count.txt. - No engine baked into the golden: the Godot editor binary + export templates are downloaded per-job by the
godot-setup.ymltemplate and restored from the pipeline cache by version pin — image swaps never lag an engine upgrade. - Azure agent download:
download.agent.dev.azure.com(the legacyvstsagentpackage.azureedge.netCDN was decommissioned). - Interpreter: PowerShell 7.6.2 system-wide on host + golden; loop service + bootstrap run under it. Avoids the 5.1 / 7 syntax footgun.
Secrets the agents do not carry
Bake nothing project-specific into the agent images. Per-pipeline secrets live in ADO variable groups:
ios-signing—MATCH_GIT_URL/MATCH_PASSWORD/ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_P8. Consumed by fastlane viamatch+ the App Store Connect API.android-signing— keystore base64 + alias + passwords, consumed by Godot Android exports and Tauri Android signing.- (future)
tauri-signing— TAURI updater key + Windows / macOS code-signing cert pairs, when those tracks come online.
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:
node_modules/pnpm storekeyed by the workspacepnpm-lock.yaml.- Cargo registry + git keyed by each workspace's
src-tauri/Cargo.lock. Pinned viaCARGO_HOME=$(Pipeline.Workspace)/.cargoso the path is identical across Linux container / macOS VM / Windows VM regardless of each agent's native cargo home. - Godot editor binary + export templates keyed by the
GODOT_VERSIONpin (restored bygodot-setup.yml). Quiz.Stage/.godot/import cache keyed by a hash overproject.godot+ the asset tree — a warm cache turns the headless import step from minutes into seconds.
Anything reproducible from sources alone (build outputs, intermediate dist/, *.app/) is not cached.
See also
- Mac mini runbook:
.claude/context/build-agents.md. - Windows host runbook:
C:\ci\STATUS.mdon REGGIESPC (192.168.50.31). - Pipeline templates + per-app YAML: CI / CD.
Testing
How testing is practised on this project. This page is the development-process source of truth — the Build Plan deliberately does not restate test tasks per item; instead the build plan's Definition of Done references this page.
Test-driven development is the default
For feature work, write the failing test first, make it pass with the smallest viable change, then refactor. The "feature work" boundary is anything that delivers user-visible behaviour, exercises a network protocol, or touches scoring/persistence/rendering pipelines.
The following work is excepted from TDD:
- Initial project scaffolding, scene/folder relocations, project-settings and export-preset tweaks, boot-dispatch skeleton, splash skeleton — captured in Per-App Scaffolding. Tests on bootstrap glue are flaky and brittle to scene layout changes; a headless boot smoke-run is sufficient signal at scaffolding time.
- One-off documentation, knowledgebase, or CI configuration changes.
- Dependency upgrades that don't change behaviour.
- Schema-codegen output (
Quiz.Designer/src/app/generated/andQuiz.Stage/core/generated/). The hand-written tests are on the schemas themselves underschemas/fixtures/and on the consumers of the generated types, not on the generated code.
Anything else: failing test first.
Where each kind of test lives
| Layer | Location | Mode | Why |
|---|---|---|---|
| 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
- Test script name mirrors the production script with a
test_prefix:core/scoring/scoring_engine.gd→tests/core/scoring/test_scoring_engine.gd. Thetests/tree mirrors the source tree. - Suite-per-class for unit tests; one test function per behaviour, named
test_<behaviour>. - Async behaviour (signal waits, timers) uses GdUnit4's
await assert_signal(...)/await_idle_frame()helpers — never wall-clockOS.delay_msecsleeps. - Arrange / Act / Assert sections separated by blank lines, no comments to label them — names already do that.
- Avoid reflection on private state or scene-path-dependent fixtures unless the test is explicitly a scene test. Scene tests instantiate the scene under test; unit tests never touch the scene tree.
- Test data lives next to the test in
tests/**/test_data/— never reach into productionassets/from tests.
CI gate
Azure DevOps Pipelines runs the test suites on every PR and every push to master:
- GdUnit4 suite for
Quiz.Stageruns headless via the GdUnit4 command-line tool after a headless import:
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.
- Pure HTML mockups —
python .claude/skills/ui-mockups/scripts/screenshot.py --file docs/ui-mockups/designer/<region>.htmlwrites per-theme PNGs to.build/. - Live Angular components — the Tauri shell hosts the Angular app inside WebView2 / WKWebView, neither of which Playwright drives directly.
ng serveis the Playwright target: same Angular app, plain Chromium.python .claude/skills/ui-mockups/scripts/screenshot.py --url http://localhost:4200/drives Chromium and writes per-theme PNGs to.build/. The Tauri shell's native-chrome surface (custom titlebar, multi-window, mDNS, subprocess spawn) is verified manually viatauri dev.
Visual divergence is treated like a failing test. Anything that ships in the Designer first lands in ng serve, gets screenshot-verified, then runs identically inside the Tauri WebView.
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
- Repository Layout —
Quiz.Stage— the one-project / three-apps layout this page's table mirrors. - Separation of Concerns — why
core/tests run headless with no scene fixtures. - Per-App Scaffolding — work explicitly excepted from TDD per the section above.
- Build Plan — Definition of Done references this page rather than restating tests per item.
AI Tooling
The project uses AI tooling throughout the development workflow to accelerate implementation, surface issues early, and keep code quality high.
Tools
| Tool | Role |
|---|---|
| Claude Code | Primary AI coding assistant. Drives feature implementation, refactoring, code review, and knowledgebase maintenance. |
| OpenCode | Secondary AI coding assistant. Complements Claude Code on tasks where a different model behaviour is preferred or when running in parallel on independent work. |
| Self-hosted Qwen3.5 LLM | Local fallback for both Claude Code and OpenCode when usage limits are hit on the primary providers. Keeps the team unblocked without paying for additional cloud-LLM throughput. |
No other AI providers are part of the project's tooling.
How AI tooling is used
- Feature implementation. Engineers describe a build-plan item or user story to the AI assistant; the assistant reads the surrounding code, drafts the change, and applies it. The engineer reviews the diff, runs the tests, and merges through the standard PR flow.
- Knowledgebase maintenance. Spec changes, decision capture, and design notes are written through the AI assistant against the knowledgebase so the docs stay aligned with the code.
- Code review. AI-driven review passes are run on every PR alongside human review — the assistant flags issues a human reviewer might miss (consistency, naming, dead code, untested branches).
- Direct engine-source editing — no editor MCP. Godot needs no editor-automation bridge: scenes (
.tscn) and scripts (.gd) are text files, authored and reviewed like any other source. Validation isgodot --headless --import(asset/scene integrity), the GdUnit4 suite (Testing), andgodot --headless --check-only -s <script>for a fast script-parse check. The same headless CLI the AI uses locally is what CI runs — there is no editor-only verification path. - Failover to self-hosted Qwen3.5. When Claude Code or OpenCode hit their rate limits, the assistant transparently falls back to the project's Qwen3.5 instance running on internal hardware. Capability is reduced relative to the cloud models, but the team remains unblocked for the duration of the limit.
Conventions
- AI-generated changes are always reviewed by a human before merge — same standard as any other code change. Definition of Done is the same regardless of who (or what) wrote the code.
- AI-driven docs changes go through the same git + PR review as code changes; the auto-generated PRD / Build Plan / Design Spec outputs are derived artefacts and not edited directly (see the
docsskill). - Test discipline (Testing) applies to AI-written code identically: failing test first, end-to-end test for user-facing features, lint + format pass.
The 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:
- Dev override first — a
--app=<host|client|preview>command-line argument (read viaOS.get_cmdline_user_args()) wins, so any app launches from one project checkout or from the editor's run configuration. - Export feature tag — each export preset carries a custom feature tag (
host,client, orpreview);boot.gdchecksOS.has_feature(...). - Loads
apps/<app>/<app>_main.tscnviachange_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. |
- Export filters strip the other apps' scenes from each build where size matters (Client mobile, Preview web).
- Every preset runs the Compatibility (GL) renderer — project-wide setting; do not switch individual presets to Forward+ (Decisions).
- Android note:
project.godotmust enable ETC2/ASTC VRAM compression (textures/vram_compression/import_etc2_astc=true) or the headless Android export fails — see Build agents.
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)
- GDScript only — no C# (Decisions). Typed GDScript everywhere it is expressible;
class_nameon every reusable class. - File and folder names are
snake_case; scene + script pairs share a basename (host_main.tscn/host_main.gd). - Persistent per-app data (session snapshots, client token, settings) lives under
user://— the export preset's distinct application id keeps each app'suser://separate on shared machines. - Brand assets (fonts, palette
Resources, audio stings) live inassets/and are referenced viapreloadfromrender/andapps/— never duplicated per app. - Tests live in
tests/, mirroring the source tree (tests/core/scoring/…,tests/render/…) — see Testing.
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.
host_main.tscn— pre-quiz lobby: load a.quiz, accept Designer transfers, list connected teams, start session.play/— the slide-driven runner scene: advances through the quiz, instantiatesrender/element scenes, dispatches runtime hooks, drives scoring + session state viaSession.operator/— operator window / overlay surfaces (F-HO-25).- Session-state persistence (crash recovery per Networking — Crash recovery) is a
core/concern; the Host scene only triggers snapshot writes on the scoring / advance events.
Client (apps/client/) — the team-device app: joins a session, renders each slide's Client canvas, collects answers.
client_main.tscn— discovery + join: list visible Hosts, enter team name, photo/avatar + jingle pick, join.play/— renders the current slide's Client canvas from the samerender/scenes, dispatches input elements, shows score.- Team-identity persistence (
client_tokenper F-CL-10) is written touser://.
Preview (apps/preview/) — the Designer's embedded render target. No chrome, no networking.
preview_main.tscn— binds the Designer's JS-bridge state pushes (GodotJavaScriptBridge↔ window-level JS API) onto the samerender/scenes the Host instantiates; emits ready / rendered / error callbacks back to the AngularPreviewBridgeservice. See Designer Shell.
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
- Shared Angular code (design tokens, chrome components, WebSocket client,
command-errorenvelope handling) is extracted fromQuiz.Designer/src/lib/into a pnpm workspace package under the repo root when the Remote scaffolds — see Repository Layout. - The Remote has no slide renderer — its host-canvas mirror renders the downscaled frame stream pushed by the Host over the control message family (Live Play Protocol).
- Same quality gates as the Designer: standalone components, OnPush, signals, typed reactive forms, Playwright + Jest + a11y.
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
- F-DE-1 Stretch The author can sign in to their cloud account.
- F-DE-2 MVP The author can create a new quiz with title, description, and tags.
- F-DE-3 MVP The author can add, edit, reorder, and delete slides within a quiz. Adding is question-first: "+ Add slide" opens a question-type picker (the 8 MVP question types + Blank (interstitial)) and the chosen type scaffolds the slide from a per-type template — prompt text elements template-bound to
question.prompt, the input element pre-placed on the Client canvas and bound viaquestion.inputBinding, default scoring + timing. See Question Model. - F-DE-4 MVP The author can group contiguous slides into rounds (sections) with a round title and round-level scoring metadata, and can ungroup or reorder rounds.
- F-DE-5 MVP The author can place, position, configure, reorder, and delete elements on a slide's Host canvas (fixed 1920×1080 virtual canvas).
- F-DE-6 MVP The author can place, configure, reorder, and delete elements on a slide's Client canvas (responsive anchor-based layout — the same
components[]+core.rect-transformelement model as the Host canvas — adapting across phone, tablet, portrait, landscape). - F-DE-7 MVP The author can browse the available object-type palette (the v1 built-in catalogue) and add an instance of any object type to either canvas. The palette grows as object types are introduced across phases.
- F-DE-8 Alpha The author can attach images, audio, and video to a quiz as resources, and reference them from element properties. Media object types (
core.image,core.audio-clip,core.video) land in Alpha; MVP supports the resource-attachment plumbing only insofar as the.quizpackage format is canonical.core.audio-clipexposes aplayTriggerfield (on-entry/on-cue/after-delay) so a "music question" pairs an audio clip with one of the MVP question types (first-letter / free-text / numeric / multiple-choice) without needing a dedicatedmusictype. - F-DE-9 MVP The author can configure scoring per question (with defaults per round in
manifest.rounds[].scoring): base points, speed bonus (speedBonus: { enabled, maxBonusPoints, curve }—linearis the MVP curve; Beta adds furthercurvevalues, not a separate concept), and tiebreak (fastest/shared/host-decides). Scoring lives on the question node, not the slide — interstitial slides cannot score. See Question Model. - F-DE-10 MVP The author can configure timing per question (and defaults per round): question duration, whether time-up locks Client input, whether the quizmaster can override the timer live, and a unified
lateSubmissionnode —policy: reject / accept-full / accept-penaltyplus optional window + penalty fraction. Timing lives on the question node — interstitial slides have no timing lock. (The old split between a timing enum and a separate scoring-side late penalty is retired; per-second decay is cut.) - F-DE-11 MVP The author can preview a slide's Host and Client canvases live in the embedded
Quiz.Stagepreview surface as they edit. - F-DE-12 MVP The author can save a quiz to the local file system as a
.quizfile. - F-DE-13 MVP The author can re-open an existing
.quizfile from the local file system. - F-DE-14 MVP The Designer can discover Hosts on the local Wi-Fi network (via Bonjour/mDNS) and send a saved
.quizfile to a chosen Host over the local network. - F-DE-15 Stretch The author can save a quiz to the cloud, creating a new version, and view/restore version history.
- F-DE-16 Stretch The author can soft-delete a quiz and recover it from a "trash" view within 30 days.
- F-DE-17 Stretch The author can use stylus input for sketching and annotation where useful (Apple Pencil on iPad; equivalent stylus on supported non-Apple tablets). v1 is touch-only on every platform — drawing inputs accept finger touch; pressure/tilt/palm-rejection is the stretch addition.
- F-DE-18 MVP The
.quizpackage format is canonical: the manifest declares every object type used and its required schema version. v1 packages contain no runtime code. - F-DE-19 MVP The author can attach host-notes to each slide — free-form text authored in a PowerPoint-style notes pane below the canvas in the Designer (always visible while editing a slide). Notes accept either plain text or markdown; markdown is rendered at runtime. Notes appear on the Remote and on the Host operator window (per F-HO-25) during play — never on the audience-facing Host main display and never on any Client. Used for hints, presentation cues, and quizmaster colour — the answer key itself is first-class on the question node (F-DE-34), not a notes convention.
- F-DE-20 MVP The author can configure each placed element's reveal trigger (on slide entry / on quizmaster trigger / after delay) and reveal animation. The reveal field is part of every element on every slide.
- F-DE-21 Beta The author can define the per-team theming options for the quiz: a palette of team colours and a set of avatars Clients can pick from at join. See Beta — Per-team theming.
- F-DE-22 Stretch The author can request AI-generated question suggestions on a topic (multiple-choice options, distractors, host-notes drafts). Generated content is staged for the author's review and explicit confirmation before it lands in a slide. See Stretch — AI-aided quiz authoring.
- F-DE-23 Stretch The author can clone a quiz template from the cloud library as the starting point for a new quiz. Depends on cloud-backed authoring.
- F-DE-24 Stretch The author can build a personal question bank — questions decoupled from any specific quiz — and pull questions from the bank into one or more quizzes. Depends on cloud-backed authoring.
- F-DE-25 Beta The Designer chrome ships dark and light themes. The author picks one as their working theme; the choice is persisted in app settings and is independent of the per-quiz theme an authored quiz declares.
- F-DE-26 Beta The author can declare a theme for the quiz in the manifest (
dark,light, or brand preset). The Host, Client, and Remote render the quiz against this theme during play. - F-DE-27 MVP The author can run the current quiz starting at the currently-selected slide from the Designer (toolbar "▸ Run" CTA or
F5). Activation is fire-and-spawn with no confirmation dialog — the Designer immediately spawns a local Host process on the same machine, passing the in-memory.quiz(or its on-disk path) and a start-at-slide pointer. The spawned Host registers an Esc-key handler that quits the runner and returns focus to the Designer; the Designer process remains alive throughout. Used for quick author-side validation without going through the network push-to-Host flow (F-DE-14). Push-to-Host stays as the network-discovery path; run-current-slide is local-machine-only. - F-DE-28 MVP The author can attach buzzer jingles to a quiz — short audio clips bundled inside the
.quizunderresources/audio/buzzers/and listed in the manifest'sbuzzers[]array. These are the clips a team can pick from at join (F-CL-15) and the Host plays when that team submits a buzzer answer (F-HO-27). The bundling avoids music-licensing exposure on the app — the author owns the clips they ship. Required asset shape: short (≤ 4 s recommended, ≤ 8 s hard cap), mono or stereo, MP3 / OGG / WAV. Depends on the.quizpackage format gaining theresources/audio/buzzers/bucket + manifest field. - F-DE-29 MVP The Designer ships a built-in default jingle library — a small curated set of generic buzzer clips covering the common "ding" / "buzz" / "horn" / "fanfare" archetypes. The author browses the library in the Designer and adds chosen clips to the active quiz; the selected clips are copied into the
.quizpackage'sresources/audio/buzzers/folder (not referenced by id from the Designer install), so the package stays self-contained when transferred to a Host that doesn't have the Designer installed. Built-in clips are pre-licensed for distribution as part of the quiz. - F-DE-30 MVP The author can attach a premade avatar set to the quiz — image files bundled under
resources/avatars/and listed in the manifest'savatars[]array. Teams pick from this set at join if they don't want to use a photo (F-CL-14). The Designer's default library also ships a small starter avatar set the author can drop in, with the same self-contained-bundle rule as buzzer jingles (F-DE-29). - F-DE-31 Beta The author can flag a slide as a wager slide. Each Client picks a multiplier from an author-defined set (e.g.
1×,2×,5×) before submitting their answer; the multiplier scales the points awarded (or deducted) by the question's scoring rule. Inspector configures the multiplier set and whether each team's chosen multiplier is visible to other teams on the Host canvas. 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. - F-DE-32 Beta The quiz manifest can declare Bingo mode. When set, the author bundles a picture pool of ≥ 25 images; each Client renders a 5 × 5 bingo card seeded uniquely per team from the pool; slide order is randomised per session; teams tap a cell when its picture appears; win condition (line / full house / pattern) is author-configurable. Lives as a manifest field + Client surface; no new object type required.
- F-DE-33 MVP The author can set a per-quiz Client leaderboard visibility toggle (
clientLeaderboardVisible, defaultfalse). Whenfalsethe Client renders only the team's own score (F-CL-8) and thecore.leaderboardelement is suppressed if placed on the Client canvas. WhentrueClients see the full leaderboard. The on-demand scoreboard overlay (F-HO-28) is unaffected — Host-only by construction. - F-DE-34 MVP Every slide is either a question or an interstitial (Question Model). Question slides carry a first-class question node:
questionType, canonicalprompt, type-specificanswerKey,scoring,timing, and aninputBindingreferencing exactly one input element on the Client canvas (validator-enforced; the input element's type must matchquestionType). Interstitial slides are presentation-only — no question node, no input elements, no scoring, no timing lock. The slide id doubles as the question id across protocol, marking, and results.
Host host
- F-HO-1 Stretch The host operator can sign in to their cloud account.
- F-HO-2 Stretch The host can list and download quizzes from the operator's cloud library.
- F-HO-3 MVP The host runs an in-process WebSocket server and advertises itself on the local network via Bonjour/mDNS — handling Designer transfers (when idle), live Client connections (during a session), and Remote control connections.
- F-HO-4 MVP The host can receive a
.quizfile pushed over the local network from a Designer. Gating: the Host shows a manual-confirm prompt for every incoming transfer, identifying the originating Designer and the file size. The Host validates the manifest on receipt and stores the file locally only if the operator accepts. - F-HO-5 MVP The host can load a
.quizfile from the local file system (manual file picker, for files transferred outside the Designer push flow). - F-HO-6 MVP Loaded quiz packages remain available offline.
- F-HO-7 MVP The host resolves every object type referenced by a loaded package against its built-in registry and refuses the package if any are missing or version-incompatible.
- F-HO-8 MVP The host can start a quiz session from a loaded package.
- F-HO-9 MVP The host displays a join screen showing connected teams (one Client device per team) and their team names.
- F-HO-10 MVP When a Client connects, the host eagerly pushes the stripped package over the WebSocket — the slides' Client-canvas content and Client-side resources for the entire quiz, minus every
question.answerKey. The Host strips keys at package-load time into a session-side answer table; phones never hold answers and all marking is Host-side (Question Model — answer-key custody). - F-HO-11 MVP The host advances through the quiz's slides in order, rendering each slide's Host canvas at the connected display's resolution and dispatching its element behaviours; "show slide N" commands are issued to Clients without further resource transfer.
- F-HO-12 Alpha The host plays attached media (images, audio, video) at the appropriate moment via the relevant element behaviours. Depends on the media object types added in Alpha.
- F-HO-13 Alpha→Beta The host runs interactive moments — animated reveals, transitions, mini-game presentation overlays — driven by element behaviours on the Host canvas. First-pass animation in Alpha; brand-true motion language and mini-game overlays in Beta.
- F-HO-14 MVP The host receives answers from clients (question-addressed:
slideIdis the question id, resolved viaquestion.inputBinding), marks them Host-side againstquestion.answerKey— Clients never pre-validate; every mark returns viascoreUpdate— applies the question's scoring rules (including thelateSubmissionpolicy per F-DE-10), and displays the leaderboard when the slide places acore.leaderboardelement with a triggered reveal. - F-HO-15 MVP The host handles client disconnects and reconnects gracefully (a Client device backgrounding does not crash the session); a reconnecting client may re-receive the eager push or a delta.
- F-HO-16 MVP The host is authoritative on time. It emits a periodic "time-remaining" tick and an authoritative "lock" message at time-up; Clients reconcile against this.
- F-HO-17 MVP The host operator can override the timer live for the current slide: extend, skip, manually lock, manually unlock. Override via the Remote is part of the Alpha rich-command set — see F-HO-24 and F-RE-9.
- F-HO-18 Alpha The host persists session state to disk after every scoring event and every slide advance — sufficient to resume the session if the Host process crashes and is relaunched with the same
.quizloaded. - F-HO-19 Alpha On launch with a saved session that matches the loaded
.quiz, the host prompts the operator to resume or start fresh. Resume restores the slide pointer, team list, scores, and per-element state; the host re-advertises on Bonjour and accepts reconnecting Clients as their original teams. - F-HO-20 MVP The host accepts a paired Remote over the WebSocket on a control message family. Pairing offers both options simultaneously: the Host displays a short numeric pairing code and a QR encoding the same code. The Remote can type the code manually or scan. Resolved per Open Questions #9.
- F-HO-21 MVP The host streams a periodic mirror of its current display, the current slide's host-notes, and live state (scores, timer remaining, slide index) to the paired Remote, and accepts the core navigation commands (advance, go-back) from it. The richer control-command set is F-HO-24.
- F-HO-22 Stretch The host offers a broadcast / streaming-friendly display mode with chrome and styling tuned for screen-capture: subdued backgrounds, boosted overlay readability, no in-room-only prompts. See Stretch — Broadcast view.
- F-HO-23 Stretch The host recognises returning teams (from previous sessions for the same quizmaster account) and offers them their previous identity at join. Depends on cloud auth + recurring-teams identity store.
- F-HO-24 Alpha The host accepts the rich control command set from the paired Remote, on top of the MVP advance/go-back: jump to a specific slide, trigger element reveals, lock/unlock Client input, extend/skip the timer, override scoring per team. Mirror of F-RE-9.
- F-HO-25 MVP The host supports a dual-window operator view on machines with multiple displays. The author / operator picks which connected screen renders the audience-facing main display (clean slide canvas, F-HO-11) and the Host opens a second OS-level window on another screen as the operator window — a same-machine equivalent of the Remote: live preview of both the Host canvas and a representative Client canvas for the current slide (so the operator can see what teams are seeing without picking up a Client device), current slide's host-notes (F-DE-19), session HUD (timer, scores, slide index), explicit Next / Previous / Show-scoreboard controls, and the same nav + control affordances the Remote exposes (F-HO-21, F-HO-24 at Alpha). On a single-display machine the operator window can be summoned as an overlay or skipped; the audience canvas is the only required surface.
- F-HO-26 MVP The host renders the team photo (or fallback premade avatar) supplied by each team at join (F-CL-14) on the leaderboard rows, the join screen team roster, and any per-team callout (correct-answer ping, big-reveal celebration). Photos are stored in memory + the session-snapshot file for crash recovery; cleared at session end. No long-term persistence in MVP/Alpha (cloud sync of team identity is Stretch — F-HO-23).
- F-HO-27 MVP The host plays the per-team buzzer jingle (chosen at join per F-CL-15) on its audio output the moment that team's buzzer-press wins the first-press race. If a team did not pick a jingle the Host plays a generic silent-buzz / haptic cue instead. Volume is governed by the Host's audio settings; the operator can mute jingles globally without ending the session.
- F-HO-28 Alpha The host operator (and paired Remote) can summon the on-demand scoreboard overlay at any moment, independent of the current slide. The overlay is Host app chrome, not a slide element — it never streams to Clients, regardless of the per-quiz F-DE-33 toggle. Dismissing the overlay returns the audience to the slide it interrupted. Triggered from a Host operator window button ("Show scoreboard") or the paired Remote's same control.
- F-HO-29 Alpha The host operator (and paired Remote) can perform live answer-correction on the active question: edit the
answerKey(accepted-answer list for free-text / first-letter / numeric / eliminator; correct option for multiple-choice / true-false), or accept an answer the key otherwise rejects. Re-scores submitted-but-affected answers retroactively and emits a "scores updated" event so Clients show the new standing within the next tick. The marking queue (F-HO-32) is the concrete operator surface for this — editing the answer key and re-running the marker are the same operation. Available on Host operator window + paired Remote — part of the F-HO-24 / F-RE-9 rich command set. - F-HO-30 MVP Built-in answer reveal. Each question type ships a built-in answer presentation (e.g. multiple-choice highlights the correct option; free-text shows the canonical accepted answer), driven entirely from
question.answerKey— authors never hand-place duplicate answer text. The Host renders it on the audience window when the quizmaster cues Reveal answer (operator-window button + Remote command, F-RE-10); the wire shape is theanswerRevealmessage (Question Model). Custom author-styled reveal overlays are Beta. - F-HO-31 MVP Buzzer adjudication. On a
core.buzzerquestion the first press locks all buzzers and fires the winning team's jingle; the quizmaster adjudicates the verbal answer via Correct / Incorrect buttons on the Host operator window and the paired Remote (F-RE-10) — the MVP's only manual-marking surface (adjudicatemessage). Incorrect → that team is excluded (optional configurable penalty) and buzzers re-open for the rest; the press is final, no resubmission. - F-HO-32 Alpha Marking queue. The operator window lists per-team submissions for the current question (free-text especially) with accept/reject toggles that re-score retroactively. This is the surface for live answer-correction (F-HO-29) and generalises the MVP
adjudicatemessage; available on the operator window + paired Remote (F-RE-9). - F-HO-33 Alpha Session results record + export. The Host keeps a per-question results record (team → submission, verdict, points, receive timestamp) inside the session snapshot, and can export the session as JSON (final standings + per-round + per-question tables) at session end — a local file the quizmaster owns.
Client client
- F-CL-1 MVP The client discovers active hosts on the local Wi-Fi network via Bonjour/mDNS.
- F-CL-2 MVP The client allows a team to enter a team name and join a host. v1 is one shared Client device per team; there is no per-individual identity within a team.
- F-CL-3 MVP On joining, the client receives the eagerly-pushed stripped slide content and resources for the whole quiz (no
question.answerKeys — Question Model) from the host and caches them in memory. The Client renders and submits; all marking is Host-side. A late-joining Client shows a progress UI during the eager push and, on completion, jumps directly to the slide the quiz is currently on. - F-CL-4 MVP The client resolves every object type referenced by a slide against its built-in registry and reports a fatal mismatch to the host if any are missing or version-incompatible.
- F-CL-5 MVP The client renders the current slide's Client canvas with its responsive layout adapting to the device's form factor, dispatching each element's runtime behaviour.
- F-CL-6 MVP The client renders the input modality of each of the 8 MVP question types and submits to the Host — never validating answers locally (marking is Host-side, F-HO-14). MVP renderings: multiple-choice option tap (single; multi-answer in Beta), free-text entry, first-letter A–Z grid tap, numeric entry, true-false ✓/✗ tap, ranking drag-to-reorder, eliminator pick-from-remaining-options, buzzer first-press. Drawing input is Stretch.
- F-CL-7 Beta The client runs mini-games when a slide includes a mini-game element. Depends on the mini-game framework added in Beta.
- F-CL-8 MVP The client displays the team's own score by default. When the per-quiz F-DE-33
clientLeaderboardVisibletoggle is set, the client also renders other teams' scores (standings). Default is own-score-only — the full leaderboard belongs to the Host canvas. - F-CL-9 MVP The client gracefully handles disconnection and reconnection.
- F-CL-10 Alpha The client persists its team identity (a stable token issued by the Host on first join) to local storage so it can rejoin a recovered session as the same team without re-entering the team name.
- F-CL-11 MVP The client renders the Host's authoritative timer state (countdown / locked / extended / skipped) and stops accepting input when the Host emits a "lock" message.
- F-CL-12 Beta At join, the team picks a colour and an avatar from the author-defined palette; the choice carries through every Client surface and every per-team Host moment (leaderboard rows, team-callouts).
- F-CL-13 Stretch A returning team can reclaim their previous identity (name, colour, avatar) when joining a session run by the same quizmaster. Depends on cloud auth + recurring-teams identity store.
- F-CL-14 MVP At join, the team can take a photo with the device camera (Godot
CameraServer, with a native-plugin fallback — see Tech Stack and Open Questions) or pick from the quiz's bundled premade-avatar set per F-DE-30. The captured photo is centre-cropped to square, bilinear-downscaled to 256 × 256 px, JPEG-encoded at quality 75 on the Client; if the encoded size exceeds the 96 KB hard cap, the Client re-encodes at quality 60 before sending. The encoded JPEG is uploaded to the Host as part of the join message (Networking — Team join). The Host renders the photo on the leaderboard and any per-team callout (F-HO-26). The Client requests camera permission via the OS; if denied or unavailable the captain falls back to the premade avatar set without an error state. - F-CL-15 MVP At join, the team picks a buzzer jingle from the set bundled into the loaded quiz (per F-DE-28). The choice is transmitted to the Host in the join message. The Client previews each jingle on tap before commit. If the quiz has no jingles bundled the buzzer-jingle picker is hidden and the Host falls back to a silent buzz tone. Depends on the
core.buzzerquestion type (MVP).
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).
- F-RE-1 MVP The Remote discovers active Hosts on the local Wi-Fi network via Bonjour/mDNS.
- F-RE-2 MVP The Remote pairs with one Host at a time. Pairing is gated (manual confirm on the Host the first time a given Remote connects, or QR-code pairing — final UX is build-plan).
- F-RE-3 MVP The Remote opens a control-message-family WebSocket to the paired Host.
- F-RE-4 MVP The Remote renders a live, scaled-down preview of what the Host's main display is currently showing.
- F-RE-5 MVP The Remote displays the per-slide host-notes (from F-DE-19) for the current slide.
- F-RE-6 MVP The Remote displays live session state: current scores, current timer remaining (the Host's authoritative state), and current slide index within the quiz.
- F-RE-7 MVP The Remote sends core navigation commands to the Host: advance, go-back. Richer control commands are F-RE-9.
- F-RE-8 MVP The Remote handles disconnection and reconnection gracefully (Wi-Fi blip recovery; rejoins the same paired Host without re-pairing).
- F-RE-9 Alpha The Remote sends the rich control command set to the Host on top of the MVP baseline: jump to a specific slide, trigger element reveals (e.g. show the leaderboard), lock or unlock Client input, extend or skip the timer, override scoring per team, summon the on-demand scoreboard overlay (F-HO-28), drive the marking queue (F-HO-32) — the surface for live answer-correction (F-HO-29) — and remove a team from the session. Host side is F-HO-24.
- F-RE-10 MVP The Remote carries the MVP question-flow cues: cue Reveal answer (F-HO-30) and adjudicate a buzzer answer Correct / Incorrect (F-HO-31), mirroring the Host operator-window buttons.
Cross-cutting project
- F-X-1 MVP A single
schemas/JSON Schema tree is the source of truth for every cross-app data shape (quiz package, WebSocket messages across all message families). Codegen produces GDScript types + validators intoQuiz.Stage/core/generated/for the Stage apps (Host, Client, Preview) and TypeScript types +ajvvalidators into the Angular Designer and Remote, so every app stays in sync. See Repository Layout — Cross-language contract. - F-X-2 MVP Schema versioning: a quiz package declares its schema version, and the Host gracefully refuses or upgrades older packages.
- F-X-3 Beta All apps use a shared design language (typography, color palette, motion vocabulary) — see Design Specification. MVP and Alpha are visually utilitarian; the brand-true treatment lands in Beta.
- F-X-4 Beta Host, Client, and Remote read the theme from the loaded quiz manifest (per F-DE-26) and render against that theme. Default is the dark brand theme.
- F-X-5 Beta All four apps ship a full audio language: branded stings (correct / incorrect / lock / time-up / big reveal / end of round / end of quiz), transition motifs, and a light optional ambient music bed. See Design Specification — sound design.
- F-X-6 Stretch Session-code joining for internet play. When the Internet-relay is reachable, the Host advertises a short alphanumeric session code (e.g.
Q7-3K9-FX). Clients on the discover screen can type that code instead of picking a Host from the Bonjour list, and Remotes do the same on their pairing screen. On the wire the join message is identical between LAN and internet paths; only the transport differs (direct WebSocket on LAN, relay-tunnelled WebSocket via Cloudflare Durable Objects or equivalent in internet mode — see Open Questions #3).
Non-Functional Requirements
| Area | Requirement |
|---|---|
| Reliability | Live quiz sessions must survive client disconnects, the Host backgrounding briefly, and minor Wi-Fi instability. No single client misbehaving should affect other clients or the Host. Crash recovery (Alpha onwards): if the Host process crashes, relaunching the Host with the same .quiz loaded must restore the session — current slide pointer, team list, and scores — and accept reconnecting Clients as their original teams. |
| Performance | UI animations target 60 fps on iPhone 12 / equivalent Android phone or tablet and above; ≥ 60 fps on supported desktops (Windows, macOS). Question transitions complete within 500 ms of trigger on the Host. Client message round-trip target under 200 ms on local Wi-Fi. Remote control message round-trip target under 200 ms on local Wi-Fi. |
| Compatibility | Designer: Windows 10/11, macOS 12+ (desktop only in v1; Tauri 2 + system WebView — WebView2 on Windows, WKWebView on macOS). Host: Windows 10/11, macOS 12+, iPadOS 16+, Android 12+ tablets. Client and Remote: iOS 16+, Android 11+; also runs on iPad and Android tablets. Browser-hosted authoring is Stretch; iPad / Android tablet authoring is a further Stretch. All minimums are provisional — final confirmation against 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
- Unit / scene-test metrics (frame time, transition time):
Quiz.Stage/tests/performance/— GdUnit4 on the Mac mini agent. - Network round-trip metrics:
Quiz.Stage/tests/soak/running an in-process Client harness — no real Wi-Fi for CI; manual Wi-Fi validation per the build plan's Cross-platform validation section. - Crash recovery:
Quiz.Stage/tests/crash_recovery/— kills a sub-process Host launched from the test driver (OS.kill). - Real-device runs (Beta): manual checklist documented under
docs/test-plans/beta-real-device.md(build-plan item — page to author in Beta).
Provisional minimums
The compatibility row lists OS / device minimums as provisional. The Beta gate "Hardware/OS minimum versions confirmed" turns these into finals after real-device runs. Surprising regressions on older targets (e.g. Android 11 not actually working) raise the floor; positive surprises (iOS 15 still functional) may lower it.
UI surfaces and flows
UI Surfaces
Per-app surface inventories: every UI entry-point (menu item, toolbar button, dialog, panel, keyboard shortcut, context menu), what it leads to, and which mockup covers it. Used to drive mockup coverage — any row without a Mockup reference is a gap. Per-app flow diagrams (inline Mermaid in the *-flows.md sibling pages — see list below) consume these as their authoritative source. Mermaid theme + authoring conventions live in .claude/skills/mermaid-diagrams/SKILL.md.
For each app's behaviour spec see Functional Requirements. For brand / palette / typography see Design Specification. For the apps themselves see Applications.
- Designer surfaces —
*{designer}*— every authoring-shell entry-point and where it leads. - Designer flows —
*{designer}*— mermaid flowcharts of app lifecycle, File menu, authoring loop, Push, Run, Library. - Host surfaces —
*{host}*— every host-app entry-point during idle, session, recovery. - Host flows —
*{host}*— lifecycle, idle / load, join, session controls, fallback overlay, Remote pairing. - Client surfaces —
*{client}*— every client-app entry-point from join to standings. - Client flows —
*{client}*— lifecycle, discover, join + customisation, session. - Remote surfaces —
*{remote}*— every remote-app entry-point from pair to live-control. - Remote flows —
*{remote}*— lifecycle, discover + pair, paired live control.
Matrix columns
Every per-app page uses one big table per surface group with these columns:
| Column | Meaning |
|---|---|
| ID | Stable identifier — <app>.<surface-group>.<slug>. Used by flow diagrams + comments sidecar references. |
| Type | menu · submenu · toolbar · dialog · modal · panel · tab · context-menu · shortcut · gesture · notification · inline. |
| Label | UI text exactly as shown (use — for icon-only). |
| Parent | The surface this lives inside (ID or root). root = top-level chrome / shell. |
| Leads to | Surface ID(s) opened/navigated-to on activation, or inline for stateful toggle, or — for terminal actions. |
| Phase | MVP · Alpha · Beta · Production · Stretch — copied from the matching functional requirement. |
| Req | Functional-requirement ID(s) (F-DE-3, etc.) backing the surface, comma-separated. — if pure chrome. |
| Status | spec · static · interactive · built — see legend below. |
| Mockup | Path to the static mockup, or —. |
Status legend
- spec — described in this matrix; no mockup yet.
- static — covered by a static mockup under
docs/ui-mockups/<app>/. - interactive — promoted into
ui-mockups/<app>/interactive prototype. - built — implemented in app code; matrix kept in sync as state changes.
Designer surfaces designer
Every entry-point in the Designer — menus, toolbars, dialogs, panels, shortcuts, context menus. Drives mockup coverage. See UI Surfaces — matrix columns for column meanings + status legend. Linked from Designer Shell, Applications — Designer, Functional Requirements — Designer, Design Spec — Designer language.
Conventions for this page:
- Shortcut IDs map to
Ctrl+Xon Windows /⌘Xon macOS — written asCtrl Xhere for brevity. - "Leads to" of
inline= stateful toggle without surface change (e.g. dirty-flag dot, theme switch). - "Leads to" of
—= terminal action (closes dialog, advances state, but opens no new surface). - A surface ID prefixed
TODO.is not yet specified — fill before mockup work.
1. Shell chrome
The shell wraps every other surface. Lives in main-shell.html.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.chrome.titlebar |
inline | (quiz title · path · dirty-dot) | root | inline | MVP | F-DE-12 | static | main-shell |
designer.chrome.titlebar.dirty |
inline | (magenta dot) | designer.chrome.titlebar |
inline | MVP | F-DE-12 | static | main-shell |
designer.chrome.windowctl.close |
inline | × | root | designer.dialog.quit-unsaved (if dirty) |
MVP | F-DE-12 | static | quit-unsaved |
designer.chrome.windowctl.min |
inline | – | root | inline | MVP | — | static | main-shell |
designer.chrome.windowctl.max |
inline | □ | root | inline | MVP | — | static | main-shell |
designer.chrome.statusbar.autosave |
inline | "Autosaved Xs ago" | root | inline | MVP | F-DE-12 | static | main-shell |
designer.chrome.statusbar.hosts |
inline | "Hosts: N visible" | root | designer.dialog.push-to-host (on click) |
MVP | F-DE-14 | static | main-shell |
designer.chrome.statusbar.library |
inline | "Library: N assets · X GB" | root | designer.panel.library |
Alpha | F-DE-8 | static | main-shell + library-panel |
designer.chrome.statusbar.slide-counter |
inline | "Slide N / M" | root | inline | MVP | — | static | main-shell |
designer.chrome.statusbar.version |
inline | "vX.Y.Z" | root | inline | MVP | — | static | main-shell |
2. Top-level menus
The Designer uses a menu-bar pattern (File · Edit · View · Help) in the toolbar. Menu contents are currently unspecified in mockups — this matrix is the source of truth until each gets a dropdown mockup.
2.1 File menu
Parent for all file-lifecycle commands.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.menu.file |
menu | File | designer.toolbar |
designer.menu.file.* |
MVP | — | static (button) | main-shell |
designer.menu.file.new |
submenu | New quiz · Ctrl N | designer.menu.file |
designer.dialog.new-quiz |
MVP | F-DE-2 | static | new-quiz |
designer.menu.file.open |
submenu | Open… · Ctrl O | designer.menu.file |
OS file picker → main-shell (loaded) | MVP | F-DE-13 | spec | — |
designer.menu.file.open-recent |
submenu | Open recent ▸ | designer.menu.file |
designer.menu.file.open-recent.list |
MVP | F-DE-13 | spec | — |
designer.menu.file.open-recent.list |
submenu | (dynamic list, last 10) | designer.menu.file.open-recent |
main-shell (loaded) | MVP | F-DE-13 | spec | — |
designer.menu.file.save |
submenu | Save · Ctrl S | designer.menu.file |
inline (autosave on; manual save fires status pulse) | MVP | F-DE-12 | spec | — |
designer.menu.file.save-as |
submenu | Save as… · Ctrl Shift S | designer.menu.file |
OS save picker → main-shell | MVP | F-DE-12 | spec | — |
designer.menu.file.push-to-host |
submenu | Push to Host… · Ctrl Shift P | designer.menu.file |
designer.dialog.push-to-host |
MVP | F-DE-14 | static | push-to-host |
designer.menu.file.run-from-slide |
submenu | Run from current slide · F5 | designer.menu.file |
inline (fire-and-spawn local Host process) | MVP | F-DE-27 | spec | — |
designer.menu.file.restore-backup |
submenu | Restore from backup… | designer.menu.file |
designer.dialog.restore-backup |
MVP | F-DE-12 | static | restore-from-backup |
designer.menu.file.import |
submenu | Import quiz package… | designer.menu.file |
OS file picker | Beta | F-DE-18 | spec | — |
designer.menu.file.export |
submenu | Export .quiz… |
designer.menu.file |
OS save picker | MVP | F-DE-12, F-DE-18 | spec | — |
designer.menu.file.preferences |
submenu | Preferences… · Ctrl , | designer.menu.file |
designer.dialog.preferences |
MVP | F-DE-25 | spec | — |
designer.menu.file.signin |
submenu | Sign in to cloud… | designer.menu.file |
TODO.designer.dialog.signin |
Stretch | F-DE-1, F-DE-15 | spec | — |
designer.menu.file.quit |
submenu | Quit · Ctrl Q | designer.menu.file |
designer.dialog.quit-unsaved (if dirty) or — |
MVP | F-DE-12 | static | quit-unsaved |
2.2 Edit menu
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.menu.edit |
menu | Edit | designer.toolbar |
designer.menu.edit.* |
MVP | — | static (button) | main-shell |
designer.menu.edit.undo |
submenu | Undo · Ctrl Z | designer.menu.edit |
inline | MVP | F-DE-3 | static (toolbar mirror) | main-shell |
designer.menu.edit.redo |
submenu | Redo · Ctrl Shift Z | designer.menu.edit |
inline | MVP | F-DE-3 | static (toolbar mirror) | main-shell |
designer.menu.edit.cut |
submenu | Cut · Ctrl X | designer.menu.edit |
inline | MVP | F-DE-5, F-DE-6 | spec | — |
designer.menu.edit.copy |
submenu | Copy · Ctrl C | designer.menu.edit |
inline | MVP | F-DE-5, F-DE-6 | spec | — |
designer.menu.edit.paste |
submenu | Paste · Ctrl V | designer.menu.edit |
inline | MVP | F-DE-5, F-DE-6 | spec | — |
designer.menu.edit.duplicate |
submenu | Duplicate · Ctrl D | designer.menu.edit |
inline | MVP | F-DE-3, F-DE-5 | spec | — |
designer.menu.edit.delete |
submenu | Delete · Del | designer.menu.edit |
inline | MVP | F-DE-3, F-DE-5 | spec | — |
designer.menu.edit.select-all |
submenu | Select all · Ctrl A | designer.menu.edit |
inline (selects all slides or all elements depending on focus) | MVP | F-DE-3 | spec | — |
designer.menu.edit.find |
submenu | Find in quiz… · Ctrl F | designer.menu.edit |
TODO.designer.panel.find |
Beta | — | spec | — |
2.3 View menu
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.menu.view |
menu | View | designer.toolbar |
designer.menu.view.* |
MVP | — | static (button) | main-shell |
designer.menu.view.design-mode |
submenu | Design mode | designer.menu.view |
designer.canvas.modetab.design |
MVP | F-DE-5, F-DE-6 | static (tab mirror) | main-shell |
designer.menu.view.preview-mode |
submenu | Preview mode | designer.menu.view |
designer.canvas.modetab.preview |
MVP | F-DE-11 | static (tab mirror) | main-shell |
designer.menu.view.both-canvases |
submenu | Both canvases | designer.menu.view |
inline (canvas seg → Both) | MVP | F-DE-5, F-DE-6 | static (seg mirror) | main-shell |
designer.menu.view.host-only |
submenu | Host only | designer.menu.view |
inline (canvas seg → Host) | MVP | F-DE-5 | static (seg mirror) | main-shell |
designer.menu.view.client-only |
submenu | Client only | designer.menu.view |
inline (canvas seg → Client) | MVP | F-DE-6 | static (seg mirror) | main-shell |
designer.menu.view.zoom-in |
submenu | Zoom in · Ctrl + | designer.menu.view |
inline | MVP | F-DE-11 | spec | — |
designer.menu.view.zoom-out |
submenu | Zoom out · Ctrl − | designer.menu.view |
inline | MVP | F-DE-11 | spec | — |
designer.menu.view.zoom-fit |
submenu | Fit to window · Ctrl 0 | designer.menu.view |
inline | MVP | F-DE-11 | spec | — |
designer.menu.view.zoom-100 |
submenu | 100% · Ctrl 1 | designer.menu.view |
inline | MVP | F-DE-11 | spec | — |
designer.menu.view.show-rulers |
submenu | Show rulers | designer.menu.view |
inline | Beta | F-DE-5 | spec | — |
designer.menu.view.show-guides |
submenu | Show smart guides | designer.menu.view |
inline | Beta | F-DE-5 | spec | — |
designer.menu.view.show-grid |
submenu | Show grid | designer.menu.view |
inline | Beta | F-DE-5 | spec | — |
designer.menu.view.theme |
submenu | Theme ▸ Dark/Light | designer.menu.view |
inline (theme switch) | Beta | F-DE-25 | static (chip in every mockup) | (all) |
designer.menu.view.toggle-slidelist |
submenu | Slide list | designer.menu.view |
inline (toggle left panel) | MVP | F-DE-3 | spec | — |
designer.menu.view.toggle-inspector |
submenu | Properties / Insert pane | designer.menu.view |
inline (toggle right panel) | MVP | F-DE-5, F-DE-7 | spec | — |
designer.menu.view.toggle-library |
submenu | Library panel | designer.menu.view |
designer.panel.library |
Alpha | F-DE-8 | static (panel) | library-panel |
2.4 Help menu
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.menu.help |
menu | Help | designer.toolbar |
designer.menu.help.* |
MVP | — | static (button) | main-shell |
designer.menu.help.docs |
submenu | Documentation | designer.menu.help |
external browser | MVP | — | spec | — |
designer.menu.help.shortcuts |
submenu | Keyboard shortcuts · Ctrl ? | designer.menu.help |
TODO.designer.dialog.shortcuts |
MVP | — | spec | — |
designer.menu.help.release-notes |
submenu | Release notes | designer.menu.help |
external browser | MVP | — | spec | — |
designer.menu.help.report-issue |
submenu | Report an issue | designer.menu.help |
external browser | MVP | — | spec | — |
designer.menu.help.about |
submenu | About Quiz Designer | designer.menu.help |
TODO.designer.dialog.about |
MVP | — | spec | — |
3. Toolbar
Always-visible buttons sitting below the title bar.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
designer.toolbar |
inline | (menu + actions row) | root | — | MVP | — | static | main-shell |
designer.toolbar.undo |
toolbar | ↺ Undo | designer.toolbar |
inline | MVP | F-DE-3 | static | main-shell |
designer.toolbar.redo |
toolbar | ↻ Redo | designer.toolbar |
inline | MVP | F-DE-3 | static | main-shell |
designer.toolbar.run |
toolbar | (CTA) ▸ Run from slide · F5 | designer.toolbar |
inline (fire-and-spawn local Host process; Esc inside runner returns to Designer) | MVP | F-DE-27 | spec | — |
Push-to-Host is File-menu only — no toolbar CTA. The toolbar CTA slot is reserved for Run from slide, which spawns a local Host process on the same machine for fast author-side validation (F-DE-27).
4. Splash + first-launch flow
Pre-shell window shown at app start. Lives in splash.html and empty-state.html.
The splash doubles as the boot loading screen — Tauri opens it first, the Angular workspace boots inside it (schema codegen check, library index hydration, Quiz.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:
- ~~Preferences scope~~ — MVP-minimal: Theme · Autosave · Defaults tabs, built to extend.
- ~~Host-notes placement~~ — PowerPoint-style notes pane below the canvas, always visible. Markdown or plain text. Renders at runtime on Remote + Host operator window (F-DE-19, F-HO-25).
- ~~Push-to-Host CTA~~ — File-menu only; toolbar CTA slot belongs to Run from slide (F-DE-27, F5).
- ~~Remote pairing UX~~ — both numeric code and QR ship (Open Questions #9).
- ~~Run-from-slide confirm~~ — fire-and-spawn instantly, no confirm. Esc inside runner returns to Designer.
- ~~Element / slide / round context-menus~~ — order + grouping recorded in §7 (element), §6 (slide / round) above.
- ~~Lock semantics~~ — position / size / rotation locked on canvas; inspector edits stay available; lock badge in selection chrome.
- ~~Reveal trigger~~ — every element has a
revealfield in the right-pane Properties inspector; no modal dialog. See [Shared element properties. - ~~Round scoring~~ — selecting a round-header swaps the right-pane Properties to round-level fields (same pattern as element / slide selection). No modal.
- ~~Scoreboard~~ —
core.leaderboardis an object type, configured via the inspector like every other element (top-N teams, animation, etc.). See object-types.md.
Resolved 2026-06-12 (question-first model — see Question Model):
- ~~Add-slide behaviour~~ — + Add slide / + Add slide to round open the question-type picker (8 MVP types + Blank interstitial); picking scaffolds prompt + bound input + default scoring/timing.
- ~~Insert palette QUESTIONS category~~ — input elements left the palette; the question-type scaffold owns the question's single input element. Palette = content / display elements only.
Open:
- Recent files (all) dialog — list, search, filter.
- Shortcuts dialog — auto-generated from keymap table or hand-written.
- About dialog — version, build hash, credits, license content.
- Library import resource dialog — drop-target + form vs straight-through OS picker.
- Multi-select operations spec — already mocked but matrix entries thin; which bulk ops are supported on N selected slides / elements.
- Animation catalogue —
reveal.animationkeys (Beta-phase brand-true motion language). - Insert palette taxonomy growth — when new 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
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
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:
+ 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):
- Inspector — selecting a question slide shows a question-first inspector: Prompt · Answer key (per-type editor) · Scoring · Timing at the top; presentation concerns (canvas background, …) below. Interstitial slides show a presentation-only inspector. Scoring and timing live on the question node, never on the slide.
- Slide rail — every row badges its kind / question-type glyph. A question slide with a missing or duplicate input binding renders a blocking badge, and package export refuses while any slide is invalid.
- Insert palette — carries content / display elements only (text, image, timer, leaderboard, …). The question's input element comes from the scaffold; any action that would introduce a second input element onto a question slide (palette drag, paste, duplicate) is rejected with a hint — "one input per question — change the question type instead". Changing the question type re-scaffolds the input element (confirm if it was customised).
- Answer reveal — the Designer does not author answer-reveal slides. Reveal presentation is built-in per question type, driven from the answer key at runtime (custom reveal styling is Beta).
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
Bonjour host list] -->|pick host| D2[Transferring
chunked CRC32 + resume] D2 -->|host accepts| D3[Done banner] D3 --> MS[Main shell] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class MS terminal
Failure branches (each surfaces inside the dialog, no separate screen): host rejects → returns to host picker; connection drops → in-place resume-from-offset, progress bar reflects retries; CRC fail → automatic re-send of failed chunks.
5. Run from slide (local)
Mockups: Main shell — preview mode
Designer process stays alive while the Host child runs. On a multi-display setup the Host opens its operator + audience windows automatically. Esc inside the Host quits the child and returns focus to the Designer.
6. Library
Mockups: Library panel · Library picker · Import resource
Asset row context menu (right-click): Rename · Replace file… (→ OS picker) · Reveal in OS browser · Delete (confirm if in use). Each mutates the panel in place. The picker (modal) is the only navigational sub-screen — it returns to Main shell with the picked asset wired to the active property field.
See also: Designer surfaces · Architecture — Designer shell · Functional Requirements — Designer.
Host surfaces host
Every entry-point in the Host app — idle screens, library, receive-from-Designer flow, join screen, in-session controls, Remote pairing, recovery. No mockups exist yet — every row below is spec until backfilled. See UI Surfaces — matrix columns for column meanings.
Linked from Applications — Host, Functional Requirements — Host, Design Spec — 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 |
host.screen.join |
inline | MVP | F-HO-3, F-HO-9 | spec | — |
host.join.session-code |
panel | "Or type code · Q7-3K9-FX" | host.screen.join |
inline | Stretch | F-X-6 | spec | — |
host.join.team-roster.photo |
inline | (team photo on roster row) | host.join.team-roster |
inline | Alpha | F-HO-26, F-CL-14 | spec | — |
host.join.team-row.context |
context-menu | Long-press / right-click team row | join roster | host.menu.team-context.* |
MVP | F-HO-9 | spec | — |
host.menu.team-context.rename |
submenu | Rename team | team-context | inline | MVP | F-HO-9 | spec | — |
host.menu.team-context.kick |
submenu | Kick team | team-context | inline (confirm) | MVP | F-HO-15 | spec | — |
host.menu.team-context.assign-colour |
submenu | Assign team colour | team-context | inline | Beta | F-CL-12, F-DE-21 | spec | — |
host.join.start-button |
toolbar | Start quiz | host.screen.join |
host.screen.session |
MVP | F-HO-8 | spec | — |
host.join.back-button |
toolbar | Back to library | host.screen.join |
host.screen.idle (confirm if teams joined) |
MVP | F-HO-8 | spec | — |
5. Session surfaces
In-session controls — slide rendering, navigation, timer override, scoring overrides.
Audience window (the projector / TV surface) shows only the slide canvas + audience-safe overlays. Operator window (F-HO-25, second display or single-display overlay) holds every control surface, mirror, notes, and HUD — same controls a paired Remote can drive.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.session.audience.canvas |
inline | (current slide Host canvas) | audience window | inline | MVP | F-HO-11 | spec | — |
host.session.operator.window |
panel | Operator window | second display / overlay | — | MVP | F-HO-25 | spec | — |
host.session.operator.mirror |
inline | (audience-screen mirror) | host.session.operator.controls |
inline | MVP | F-HO-25 | spec | — |
host.session.operator.notes |
inline | (current slide notes, markdown-rendered) | host.session.operator.controls |
inline (scroll) | MVP | F-DE-19, F-HO-25 | spec | — |
host.session.operator.controls |
inline | (nav + timer + reveal + scoring) | host.session.operator.controls |
— | MVP | F-HO-17, F-HO-25 | spec | — |
host.session.fallback-overlay |
inline | Single-display overlay fallback | host.session.audience.canvas |
inline (tap / mouse to summon) | MVP | F-HO-11, F-HO-17, F-HO-25 | spec | — |
host.session.toolbar.next |
toolbar | Next slide · → | host.session.operator.controls |
inline | MVP | F-HO-11 | spec | — |
host.session.toolbar.prev |
toolbar | Previous slide · ← | host.session.operator.controls |
inline | MVP | F-HO-11 | spec | — |
host.session.toolbar.timer-extend |
toolbar | + 10 s | host.session.operator.controls |
inline | MVP | F-HO-17 | spec | — |
host.session.toolbar.timer-skip |
toolbar | Skip timer | host.session.operator.controls |
inline | MVP | F-HO-17 | spec | — |
host.session.toolbar.timer-lock |
toolbar | Lock now | host.session.operator.controls |
inline | MVP | F-HO-17 | spec | — |
host.session.toolbar.timer-unlock |
toolbar | Unlock | host.session.operator.controls |
inline | MVP | F-HO-17 | spec | — |
host.session.toolbar.reveal |
toolbar | Trigger reveal | host.session.operator.controls |
inline | Alpha | F-DE-20, F-HO-24 | spec | — |
host.session.toolbar.show-leaderboard |
toolbar | Show leaderboard | host.session.operator.controls |
inline | MVP | F-HO-14 | spec | — |
host.session.toolbar.jump-to-slide |
toolbar | Jump to slide… | host.session.operator.controls |
TODO.host.dialog.jump-to-slide |
Alpha | F-HO-24 | spec | — |
host.session.toolbar.score-override |
toolbar | Score overrides | host.session.operator.controls |
TODO.host.dialog.score-overrides |
Alpha | F-HO-24 | spec | — |
host.session.toolbar.pause |
toolbar | Pause session | host.session.operator.controls |
TODO.host.dialog.pause |
MVP | F-HO-11 | spec | — |
host.session.toolbar.end-session |
toolbar | End session… | host.session.operator.controls |
TODO.host.dialog.end-session (confirm) |
MVP | F-HO-14 | spec | — |
host.session.hud.timer |
inline | (countdown) | host.session.canvas |
inline | MVP | F-HO-16 | spec | — |
host.session.hud.slide-counter |
inline | "Slide N / M" | host.session.operator.controls |
inline | MVP | F-HO-11 | spec | — |
host.session.hud.team-status |
inline | (teams who've answered) | host.session.operator.controls |
inline | MVP | F-HO-14, F-HO-15 | spec | — |
host.session.hud.team-photo |
inline | (team photo on leaderboard + callouts) | host.session.audience.canvas · operator scoreboard |
inline | Alpha | F-HO-26, F-CL-14 | spec | — |
host.session.audio.jingle |
inline | (per-team buzzer jingle playback) | host.session.audience.canvas (audio out) |
inline (fires on buzzer-in first-press win) | Alpha | F-HO-27, F-CL-15 | spec | — |
host.session.operator.jingle-mute |
toolbar | Mute jingles | host.session.operator.controls |
inline (toggle, session-scoped) | Alpha | F-HO-27 | spec | — |
host.dialog.client-disconnect |
notification | " |
session | inline | MVP | F-HO-15 | spec | — |
host.dialog.jump-to-slide |
dialog | Jump to slide | host.session.operator.controls |
inline | Alpha | F-HO-24 | static | host/jump-to-slide.html |
host.dialog.score-overrides |
dialog | Score overrides | host.session.operator.controls |
inline | Alpha | F-HO-24 | static | host/score-overrides.html |
host.dialog.pause |
dialog | Session paused | host.session.operator.controls |
resume / end | MVP | F-HO-11 | static | host/pause.html |
host.dialog.end-session |
dialog | End this session? | host.session.operator.controls |
host.screen.session.end / cancel |
MVP | F-HO-14 | static | host/end-session-confirm.html |
6. Remote pairing surfaces
Pairing offers both options simultaneously per F-HO-20 — short numeric pairing code + QR encoding the same code. Remote can type or scan.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.dialog.remote-pair-prompt |
dialog | Pair a Remote — code + QR | join screen · settings · operator menu | inline (waits for Remote) | MVP | F-HO-20 | spec | — |
host.dialog.remote-pair-prompt.code |
inline | (short numeric pairing code) | pair-prompt | inline | MVP | F-HO-20 | spec | — |
host.dialog.remote-pair-prompt.qr |
inline | (QR encoding pairing code) | pair-prompt | inline | MVP | F-HO-20 | spec | — |
host.dialog.remote-pair-prompt.regenerate |
toolbar | Regenerate code | pair-prompt | inline | MVP | F-HO-20 | spec | — |
host.dialog.remote-pair-accept |
dialog | Accept Remote |
Remote attempt | accept → paired / reject → dismissed | MVP | F-HO-20 | spec | — |
host.session.hud.remote-indicator |
inline | (Remote-paired icon) | host.session.operator.controls |
inline | MVP | F-HO-20, F-HO-21 | spec | — |
host.dialog.remote-disconnected |
notification | Remote disconnected | session | inline | MVP | F-HO-20 | spec | — |
7. Settings + ancillary
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
host.dialog.settings |
dialog | Settings | idle toolbar | inline tabs (display, audio, network, advanced) | MVP | — | static | host/settings.html |
host.dialog.settings.display |
tab | Display | settings | inline (HDMI / AirPlay / resolution / overscan) | MVP | F-HO-11 | static | host/settings.html |
TODO.host.dialog.settings.display.pick-audience |
inline | Audience display ▾ | display tab | inline (select connected screen) | MVP | F-HO-25 | spec | — |
TODO.host.dialog.settings.display.operator-window |
inline | Operator window ▾ | display tab | inline (select other screen / disable / overlay-on-single-screen) | MVP | F-HO-25 | spec | — |
TODO.host.dialog.settings.audio |
tab | Audio | settings | inline | Beta | F-X-5 | spec | — |
TODO.host.dialog.settings.network |
tab | Network | settings | inline (advertised name, Wi-Fi info) | MVP | F-HO-3 | spec | — |
TODO.host.dialog.settings.advanced |
tab | Advanced | settings | inline (logs, telemetry, factory reset) | MVP | — | spec | — |
TODO.host.dialog.signin |
dialog | Sign in to cloud | idle toolbar | inline | Stretch | F-HO-1 | spec | — |
TODO.host.dialog.cloud-library |
dialog | Cloud library | idle toolbar | inline (browse + download) | Stretch | F-HO-2 | spec | — |
TODO.host.dialog.broadcast-mode |
dialog | Broadcast mode | settings · session overlay | inline | Stretch | F-HO-22 | spec | — |
8. Gaps + decisions to make
Everything is currently spec. Mockup priorities before any other:
- Idle screen — first user impression after launch; what does it look like with 0 / 1 / N loaded quizzes? Network banner copy and placement. Session-code surface (Stretch) — placement when both LAN-only and internet-joinable.
- Join screen — QR placement, team-roster density, copy when 0 teams joined.
- Operator window layout (F-HO-25) — mirror size vs notes prominence vs control density; one-handed vs sit-at-laptop ergonomics.
- Single-display fallback overlay — gesture/tap to summon, auto-hide timing, which controls are visible by default vs in a sub-menu.
- Incoming-transfer modal — accept/reject affordance, mid-transfer progress, Designer identification copy.
- Resume-session dialog — Alpha-phase; copy when the saved-session quiz no longer matches.
- Display-config UX — first-launch with multi-display: auto-detect or always ask. Drag-to-arrange screens visual? OS-native picker?
- Settings dialog — minimum surface for MVP vs Beta.
Host flows host
Surface-to-surface navigation flows for the Host. Each diagram shows the happy path forward only — same-screen actions (next slide, +10s, lock, mute jingles, etc.) live in prose under the diagram, not as self-loops on the canvas. Authoring conventions and theme tokens live in .claude/skills/mermaid-diagrams/SKILL.md.
Surfaces in Host surfaces. Functional requirements in Functional Requirements — Host.
1. App lifecycle
Mockups: Idle · Resume session · Session — operator · Final standings
Off-path returns (each is a banner / standard back action, not a primary edge): Final standings → Back returns to Idle for the next quiz; session crash → relaunch surfaces Resume dialog.
2. Idle and load
Mockups: Idle · Incoming transfer · Package incompatible · Settings
loaded quizzes + toolbar] -->|tap quiz| LD[Loaded · awaiting start] ID -->|Load file… / incoming push| RCV[Receive + validate] RCV -->|valid| ID RCV -->|type mismatch| PI[Package-incompatible] LD --> JO[Join screen] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class JO terminal
On-screen actions (no navigation): toolbar — Settings dialog, Sign-in (Stretch), session-code visibility. Quiz row long-press opens context menu (Start session → Join · Preview slides → Loaded · Delete from device). Incoming-transfer prompt confirms → progress bar → done banner; reject returns to Idle.
3. Join and start
Mockups: Join screen · Remote pair prompt
QR + URL + code + roster] -->|Start quiz ▸| SE[Session] JO --> RP[Remote pair prompt] RP -->|Remote accepted| JO classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class SE terminal
Roster actions (no navigation): team rows fill as Clients connect; long-press a row → context menu (Rename / Kick / Assign colour — Beta). Back to library: prompts confirm if any teams have joined; returns to Idle.
4. Session controls (operator window)
Mockups: Operator window · Audience window · Jump to slide · Score overrides · Pause · End-session confirm · Final standings
operator + audience windows] SE --> PA[Paused overlay] SE --> JTS[Jump dialog — Alpha] SE --> SO[Score overrides — Alpha] SE --> ENC[End-session confirm] ENC -->|End| EN[Final standings] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class EN terminal
Inline session controls (no navigation — buttons fire commands, audience window updates in place): Next slide / Previous · + 10 s / Skip timer / Lock now / Unlock · Show leaderboard · Trigger reveal (Alpha) · Mute jingles. Off-path overlays: Paused overlay → Resume; Jump / Overrides modals → Apply or Cancel returns to Session. Disconnects surface as banners (Client disconnect · Remote disconnect) and disappear on reconnect; no navigation.
5. Single-display fallback
Mockups: Audience window (overlay state)
On a machine with one display the operator window becomes a summoned overlay, not a separate window. Inputs (tap / move mouse) reveal the control row; auto-hides after 4 s of inactivity. Both states are the same Session screen — no separate node.
6. Remote pairing
Mockups: Remote pair prompt
code + QR] --> WAIT[Wait for Remote] WAIT --> RA[Accept-Remote prompt] RA -->|Accept| PAIRED[Paired] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class PAIRED terminal
On-screen actions: Regenerate code (cycles the visible code, stays on prompt); Reject (back to waiting). Remote disconnect while paired returns the operator HUD to "no Remote paired" state inline; no navigation.
See also: Host surfaces · Networking · Functional Requirements — Host.
Client surfaces client
Every entry-point in the Client app — discover, join, in-session (responsive Client canvas + input), standings, reconnect. No mockups exist yet — every row below is spec until backfilled. See UI Surfaces — matrix columns for column meanings.
Linked from Applications — Client, Functional Requirements — Client, Design Spec.
Client targets: iPhone, Android phone, iPad, Android tablet. One shared device per team. Responsive layout — phone portrait/landscape, tablet portrait/landscape. Touch-first.
1. Top-level screens
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.screen.splash |
dialog | (logo + version) | root | client.screen.discover |
MVP | — | spec | — |
client.screen.discover |
panel | Find a host | root | client.screen.join |
MVP | F-CL-1 | spec | — |
client.screen.code-entry |
panel | Type session code | client.screen.discover |
client.screen.join (via internet relay) |
Stretch | F-X-6 | spec | — |
client.screen.join |
panel | Join — enter team name | root | client.screen.session |
MVP | F-CL-2 | spec | — |
client.screen.eager-push-progress |
panel | Loading quiz… (progress) | root | client.screen.session |
MVP | F-CL-3 | spec | — |
client.screen.session |
panel | Live slide rendering + input | root | client.screen.session.end |
MVP | F-CL-5 | spec | — |
client.screen.session.end |
panel | Final standings + thanks | root | client.screen.discover |
MVP | F-CL-8 | spec | — |
client.screen.reconnect |
panel | Reconnecting… | root | client.screen.session (on reconnect) |
MVP | F-CL-9 | spec | — |
client.screen.fatal-mismatch |
panel | This host needs a newer Client | root | client.screen.discover |
MVP | F-CL-4 | spec | — |
2. Discover surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.discover.host-list |
panel | Hosts on this Wi-Fi | client.screen.discover |
inline (select) → client.screen.join |
MVP | F-CL-1 | spec | — |
client.discover.host-row |
inline | (Host name · quiz title) | host-list | inline (select) | MVP | F-CL-1 | spec | — |
client.discover.refresh |
toolbar | Refresh | client.screen.discover |
inline | MVP | F-CL-1 | spec | — |
client.discover.code-entry-toggle |
toolbar | Have a code? Tap to enter | client.screen.discover |
client.screen.code-entry |
Stretch | F-X-6 | spec | — |
client.code-entry.input |
inline | Session code input (Q7-3K9-FX) | client.screen.code-entry |
inline (resolve → connect) | Stretch | F-X-6 | spec | — |
client.code-entry.submit |
toolbar | Join with code | client.screen.code-entry |
client.screen.join |
Stretch | F-X-6 | spec | — |
client.code-entry.back |
toolbar | Back to scan | client.screen.code-entry |
client.screen.discover |
Stretch | — | spec | — |
client.dialog.code-not-found |
dialog | Code didn't match | code-entry submit | client.screen.code-entry (retry) |
Stretch | F-X-6 | spec | — |
client.discover.empty-state |
inline | No hosts found (troubleshooting hints) | client.screen.discover |
inline | MVP | F-CL-1 | spec | — |
client.discover.menu |
menu | ⋯ | client.screen.discover |
client.menu.app.* |
MVP | — | spec | — |
client.menu.app.settings |
submenu | Settings | app menu | TODO.client.dialog.settings |
MVP | — | spec | — |
client.menu.app.about |
submenu | About | app menu | TODO.client.dialog.about |
MVP | — | spec | — |
3. Join surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.join.team-name |
inline | Team name input | client.screen.join |
inline | MVP | F-CL-2 | spec | — |
client.join.team-colour |
inline | Pick team colour | client.screen.join |
inline | Beta | F-CL-12 | spec | — |
client.join.team-avatar |
inline | Pick team avatar (palette tile) | client.screen.join |
inline | Beta | F-CL-12 | spec | — |
client.join.photo |
inline | Take photo / Choose photo | client.screen.join |
client.dialog.photo-capture / client.dialog.photo-crop |
Alpha | F-CL-14 | spec | — |
client.join.photo.avatar-fallback |
inline | Or pick from {N} avatars | client.screen.join |
inline (modal of premade avatars) | Alpha | F-CL-14, F-DE-30 | spec | — |
client.dialog.photo-capture |
dialog | Camera permission + capture | photo button | client.dialog.photo-crop |
Alpha | F-CL-14 | spec | — |
client.dialog.photo-crop |
dialog | Crop to square | photo-capture | inline (saves to join state) | Alpha | F-CL-14 | spec | — |
client.join.buzzer-jingle |
inline | Pick a buzzer sound | client.screen.join |
client.dialog.buzzer-picker |
Alpha | F-CL-15 | spec | — |
client.dialog.buzzer-picker |
dialog | Buzzer jingle picker (preview + select) | buzzer button | inline (saves to join state) | Alpha | F-CL-15 | spec | — |
client.join.buzzer-jingle.empty |
inline | "Quizmaster didn't add buzzers" | client.screen.join |
inline (hidden when none bundled) | Alpha | F-CL-15 | spec | — |
client.join.submit |
toolbar | Join | client.screen.join |
client.screen.eager-push-progress |
MVP | F-CL-2 | spec | — |
client.join.back |
toolbar | Back | client.screen.join |
client.screen.discover |
MVP | — | spec | — |
client.dialog.rejoin-previous |
dialog | Rejoin as |
join screen on reconnect | inline | Alpha | F-CL-10 | spec | — |
client.dialog.team-name-taken |
dialog | Team name taken | join submit | inline | MVP | F-CL-2 | spec | — |
client.dialog.host-rejected-join |
dialog | Host rejected join | join submit | client.screen.discover |
MVP | F-CL-15 | spec | — |
4. Session surfaces
The session screen is dominated by the slide's Client canvas (driven entirely by the slide's element behaviours). Persistent chrome wraps it.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.session.canvas |
inline | (current slide Client canvas) | client.screen.session |
inline | MVP | F-CL-5 | spec | — |
client.session.hud.team-name |
inline | (team name · colour · avatar) | client.screen.session |
client.session.menu (tap) |
MVP | F-CL-2, F-CL-12 | spec | — |
client.session.hud.score |
inline | (team score) | client.screen.session |
client.session.standings (tap) |
MVP | F-CL-8 | spec | — |
client.session.hud.timer |
inline | (countdown, mirrors Host) | client.session.canvas |
inline | MVP | F-CL-11 | spec | — |
client.session.hud.locked-banner |
inline | "Time's up — answers locked" | client.session.canvas |
inline | MVP | F-CL-11 | spec | — |
client.session.standings |
panel | Live standings | session HUD score | inline (swipe to dismiss) | MVP | F-CL-8 | spec | — |
client.session.menu |
menu | ⋯ | session HUD | client.menu.session.* |
MVP | — | spec | — |
client.menu.session.change-name |
submenu | Change team name | session menu | TODO.client.dialog.change-team-name |
MVP | F-CL-2 | spec | — |
client.menu.session.leave |
submenu | Leave session | session menu | confirm → client.screen.discover |
MVP | — | spec | — |
client.menu.session.report-issue |
submenu | Report an issue | session menu | external / inline | MVP | — | spec | — |
4.1 Element-driven input surfaces
Each placed element on the Client canvas owns its own input UI. These are not part of the shell — they ship with each object type. Surfaces listed here are the categories, not specific element editors.
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.element.multiple-choice |
inline | (A/B/C/D tap targets) | client.session.canvas |
inline | MVP | F-CL-6 | spec | — |
client.element.free-text |
inline | (text input) | client.session.canvas |
inline (submit) | MVP | F-CL-6 | spec | — |
client.element.drawing |
inline | (canvas + brush) | client.session.canvas |
inline (submit) | Alpha | F-CL-6, F-DE-17 | spec | — |
client.element.gesture-buzzer |
inline | (big tap target / shake) | client.session.canvas |
inline | Alpha | F-CL-6 | spec | — |
client.element.mini-game |
inline | (mini-game viewport) | client.session.canvas |
inline | Beta | F-CL-7 | spec | — |
client.element.submitted-feedback |
inline | "Answer received" | element input | inline | MVP | F-CL-6 | spec | — |
5. Reconnect + error surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.notification.reconnecting |
notification | Reconnecting… | session | inline / client.screen.reconnect |
MVP | F-CL-9 | spec | — |
client.notification.host-gone |
notification | Host disconnected | session | client.screen.discover (after timeout) |
MVP | F-CL-9 | spec | — |
client.dialog.session-ended |
dialog | Session ended | session | client.screen.session.end |
MVP | F-CL-8 | spec | — |
6. Settings + ancillary
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
client.dialog.settings |
dialog | Settings | app menu | inline (sound, vibration, accessibility) | MVP | — | static | client/settings.html |
client.dialog.change-team-name |
dialog | Change team name | session menu | inline | MVP | F-CL-2 | static | client/change-team-name.html |
client.dialog.about |
dialog | About | app menu | inline (version, build) | MVP | — | static | client/about.html |
7. Gaps + decisions to make
Mockup priorities:
- Discover screen — host list density, refresh affordance, empty-state copy. First impression after install.
- Join screen — team-name input, "Joined as
" confirmation, persistence prompt copy in Alpha. Photo capture flow + premade-avatar fallback ordering. Buzzer-picker preview affordance + truncation when many jingles are bundled. - Session HUD — what's always visible vs hidden behind a tap, how the timer renders across phone/tablet form factors.
- Element input library — placeholder editor patterns for MC / free-text / drawing / buzzer. Each object type ships its own visuals; this matrix tracks them as a category list only.
- Locked-banner copy + animation — F-CL-11.
- Standings panel — live during session vs only between rounds vs only on session-end.
- Session-code entry (Stretch) — code format display (chunked
XX-XXX-XXvs flat), what happens on a copy-paste from a chat app, how the screen relates to the LAN discover screen (toggle vs separate screen).
Client flows client
Surface-to-surface navigation for the Client. Each diagram shows the happy path forward only — same-screen actions (refresh, edit answer, swipe between panels, back-to-menu) are listed in prose under the diagram rather than drawn as self-loops. Authoring conventions and theme tokens live in .claude/skills/mermaid-diagrams/SKILL.md.
Surfaces referenced here are defined in Client surfaces. Functional requirements are in Functional Requirements — Client.
1. App lifecycle
Mockups: Splash · Discover · Join · Session · Session end
The Client follows one forward path per session. Off-path returns: session-end → Discover (rejoin next quiz); host-disconnect → Reconnecting banner → Session (auto-retry, no user action); leave-session menu → Discover. Code-entry (Stretch) sits beside Discover for internet relay joins.
2. Discover
Mockups: Discover · Code entry
Bonjour host list] -->|tap live host| JO[Join] DIS -->|Have a code? — Stretch| CE[Code-entry] -->|valid| JO classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class JO terminal
On-screen actions (no navigation): pull-to-refresh, app-menu (Settings / About), tap idle host card → "no quiz loaded" inline notice. Code-entry resolve failure shows an inline "Code didn't match" hint and stays on the code-entry screen.
3. Join and team customisation
Mockups: Team join · Photo capture · Buzzer picker
photo · avatar · buzzer · colour] CUST --> SUBMIT[Join ▸] SUBMIT --> EP[Eager-push] EP --> SE[Session] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class SE terminal
Customisation steps (each returns to the Join screen with the choice applied — modal sub-flows, not navigation): camera capture → crop-to-square; premade-avatar picker; buzzer-jingle picker (preview + select); team-colour picker (Beta).
Failure branches (each surfaces as an inline banner on the Join screen, no navigation): team name taken; host rejected join → Discover; fatal object-type mismatch → Discover; rejoin-as-previous-team dialog on reconnect.
4. Session
Mockups: Session · Session ended
slide canvas + HUD] -->|host locks input| LOCK[Locked banner] LOCK -->|host advances| SE SE -->|host ends quiz| END[Final standings] END -->|Stay for next / Leave| DIS[Discover] classDef terminal fill:#E8F5EC,stroke:#36D17B,stroke-width:2px,color:#1F1933,rx:14,ry:14 class END,DIS terminal
On-screen actions (no navigation): tap answer, edit until lock, tap score chip → standings panel (swipe to dismiss), tap team chip → session menu (Change team name / Leave / Report issue). Connection drop: Reconnecting banner overlays the session; auto-retry restores Session on reconnect, falls back to "Host gone" → Discover after timeout.
See also: Client surfaces · Functional Requirements — Client.
Remote surfaces remote
Every entry-point in the Remote app — discover, pair, mirror, host-notes, live state, MVP nav commands and the Alpha rich-control set. No mockups exist yet — every row below is spec until backfilled. See UI Surfaces — matrix columns for column meanings.
Linked from Applications — Remote, Functional Requirements — Remote, Design Spec — 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 |
discover (host selected) | remote.pair.qr-scan / remote.pair.code-entry |
MVP | F-RE-2 | spec | — |
remote.pair.qr-scan |
panel | Scan QR | choose-method | remote.screen.pair-pending |
MVP | F-RE-2 | spec | — |
remote.pair.code-entry |
panel | Enter pairing code | choose-method | remote.screen.pair-pending |
MVP | F-RE-2 | spec | — |
remote.dialog.pair-rejected |
dialog | Host rejected pairing | remote.screen.pair-pending |
remote.screen.discover |
MVP | F-RE-2 | spec | — |
remote.dialog.pair-timeout |
dialog | Host did not respond | remote.screen.pair-pending |
remote.screen.discover |
MVP | F-RE-2 | spec | — |
remote.dialog.pair-code-invalid |
dialog | Code didn't match | code-entry | remote.pair.code-entry (retry) |
MVP | F-RE-2 | spec | — |
3. Paired screen surfaces
Live control screen — laid out for one-handed use while walking. Reference layout: mirror at top, host-notes mid, nav controls bottom (final layout is a mockup decision).
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.paired.mirror |
inline | (Host display preview) | remote.screen.paired |
remote.paired.mirror.fullscreen (tap) |
MVP | F-RE-4 | spec | — |
remote.paired.mirror.fullscreen |
panel | Fullscreen mirror | mirror tap | inline (tap to dismiss) | MVP | F-RE-4 | spec | — |
remote.paired.host-notes |
inline | (per-slide host notes) | remote.screen.paired |
inline (scroll) | MVP | F-RE-5, F-DE-19 | spec | — |
remote.paired.hud.timer |
inline | (countdown) | remote.screen.paired |
inline | MVP | F-RE-6 | spec | — |
remote.paired.hud.slide-counter |
inline | "Slide N / M" | remote.screen.paired |
remote.paired.jump-to-slide (tap, Alpha) |
MVP | F-RE-6 | spec | — |
remote.paired.hud.scores |
inline | (per-team scores) | remote.screen.paired |
remote.paired.scores-detail (tap) |
MVP | F-RE-6 | spec | — |
remote.paired.scores-detail |
panel | Team scores | hud.scores | inline (back) | MVP | F-RE-6 | static | remote/scores-detail.html |
3.1 Core navigation (MVP)
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.paired.toolbar.advance |
toolbar | ▸ Advance | remote.screen.paired |
inline | MVP | F-RE-7 | spec | — |
remote.paired.toolbar.back |
toolbar | ◂ Back | remote.screen.paired |
inline | MVP | F-RE-7 | spec | — |
remote.paired.gesture.swipe-left |
gesture | Swipe left | mirror | inline (= Advance) | MVP | F-RE-7 | spec | — |
remote.paired.gesture.swipe-right |
gesture | Swipe right | mirror | inline (= Back) | MVP | F-RE-7 | spec | — |
3.2 Rich control set (Alpha — F-RE-9)
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.paired.toolbar.jump-to-slide |
toolbar | Jump to slide… | remote.screen.paired |
remote.paired.jump-to-slide |
Alpha | F-RE-9 | spec | — |
remote.paired.jump-to-slide |
panel | Slide picker | jump-to-slide button | inline (select) | Alpha | F-RE-9 | static | remote/jump-to-slide.html |
remote.paired.toolbar.reveal |
toolbar | Trigger reveal ▾ | remote.screen.paired |
remote.paired.reveal-menu |
Alpha | F-RE-9 | spec | — |
remote.paired.reveal-menu |
menu | Show leaderboard / answer / next element | reveal toolbar | inline | Alpha | F-RE-9 | spec | — |
remote.paired.toolbar.lock |
toolbar | Lock / unlock input | remote.screen.paired |
inline (toggle) | Alpha | F-RE-9 | spec | — |
remote.paired.toolbar.timer-extend |
toolbar | + 10 s | remote.screen.paired |
inline | Alpha | F-RE-9 | spec | — |
remote.paired.toolbar.timer-skip |
toolbar | Skip timer | remote.screen.paired |
inline | Alpha | F-RE-9 | spec | — |
remote.paired.toolbar.score-overrides |
toolbar | Score overrides… | remote.screen.paired |
remote.paired.score-overrides |
Alpha | F-RE-9 | spec | — |
remote.paired.score-overrides |
panel | Score overrides | toolbar | inline | Alpha | F-RE-9 | static | remote/score-overrides.html |
3.3 Paired-screen menu
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.paired.menu |
menu | ⋯ | remote.screen.paired |
remote.menu.paired.* |
MVP | — | spec | — |
remote.menu.paired.unpair |
submenu | Unpair from this Host | paired menu | remote.screen.discover |
MVP | — | spec | — |
remote.menu.paired.settings |
submenu | Settings | paired menu | remote.dialog.settings |
MVP | — | spec | — |
remote.menu.paired.about |
submenu | About | paired menu | remote.dialog.about |
MVP | — | spec | — |
4. Reconnect + error surfaces
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.notification.reconnecting |
notification | Reconnecting to Host… | paired | inline / remote.screen.reconnect |
MVP | F-RE-8 | spec | — |
remote.notification.host-gone |
notification | Host gone | paired | remote.screen.discover (after timeout) |
MVP | F-RE-8 | spec | — |
remote.notification.session-ended |
notification | Session ended on Host | paired | remote.screen.paired (idle) |
MVP | F-RE-3 | spec | — |
5. Settings + ancillary
| ID | Type | Label | Parent | Leads to | Phase | Req | Status | Mockup |
|---|---|---|---|---|---|---|---|---|
remote.dialog.settings |
dialog | Settings | app menu | inline (haptics, screen-stays-on, accessibility) | MVP | — | static | remote/settings.html |
remote.dialog.about |
dialog | About | app menu | inline | MVP | — | static | remote/about.html |
6. Gaps + decisions to make
Mockup priorities:
- Paired screen layout — mirror size vs host-notes prominence vs nav-button reach for one-handed use. Phone is the primary form factor; tablet layout is secondary.
- Pair-method-picker UX — single screen with both Scan + Enter Code on it, or a swipeable tab? Default-focused option?
- Rich control affordances — Alpha-phase but worth specifying before code lands so the layout reserves space.
- Reveal menu — what reveals are slide-generic vs object-type-specific.
- Gesture conventions — swipe-left / swipe-right vs button-only. Must not accidentally fire while walking.
Remote flows remote
Surface-to-surface navigation for the Remote. Each diagram shows the happy path forward only — same-screen actions (advance, swipe, refresh, app menu) live in prose under the diagram. Authoring conventions and theme tokens live in .claude/skills/mermaid-diagrams/SKILL.md.
Surfaces in Remote surfaces. FR in Functional Requirements — Remote.
1. App lifecycle
Mockups: Splash · Discover · Pair pending · Paired · Reconnecting
Off-path returns (each surfaces as a banner / inline error, no separate node): pair rejected / timeout → Discover; host disconnect → Reconnecting → Paired on reconnect, → Discover on timeout; user unpairs from paired menu → Discover.
2. Discover and pair
Mockups: Discover · Pair method · Pair pending
On-screen actions: pull-to-refresh; app menu (Settings / About). Code-entry error: "code didn't match" surfaces as an inline banner — stays on the code-entry screen. Pair pending failure: host rejects / timeout banner → Discover.
3. Paired — live control
Mockups: Paired · Scores detail · Jump to slide · Score overrides · Reconnecting
mirror · notes · HUD · nav] PAR --> FS[Fullscreen mirror] PAR --> SD[Scores detail] PAR --> JTS[Jump to slide — Alpha] PAR --> SO[Score overrides — Alpha] PAR --> RM[Reveal menu — Alpha]
Inline controls (no navigation — buttons fire commands, mirror keeps updating): Advance / Back; swipe-left / swipe-right; Lock / unlock input (Alpha); + 10 s, Skip timer (Alpha). App menu: Unpair (→ Discover) / Settings / About. Connection drop: Reconnecting banner over Paired; auto-retry restores Paired, falls back to "Host gone" → Discover.
See also: Remote surfaces · Networking · Functional Requirements — Remote.
Phases
MVP
Goal: Prove the production architecture end-to-end on every supported platform, with the smallest feature set that still constitutes a working live quiz. The output is a thing an author can use to design a quiz and run it for one or more teams in a room — no media, no minigames, no polish, but real.
This is the architecture. Nothing here is throwaway prototype code; the choices made in MVP carry through to Alpha, Beta, and Production.
In scope
Architecture and platform
- The Designer (Tauri 2 + Angular 21 workspace), the single
Quiz.Stage/Godot 4.x project (Host, Client, and Preview entry scenes with per-app export presets), and the Remote (Tauri 2 + Angular workspace, Tauri mobile) initialised, with the cross-language contract published as JSON Schema underschemas/— codegen producing TypeScript types for the Angular apps and GDScript types intoQuiz.Stage/core/generated/— see Repository Layout. - Azure DevOps Pipelines running per-area pipelines on Microsoft-hosted + self-hosted Mac mini agents — headless Godot
Quiz.Stageexports (host / client / preview presets) + GdUnit4 suites, Tauri Designer build (Windows installer + macOS DMG), schema codegen, Angular Jest + Playwright suites, Rust fmt / clippy / tests, on every PR + push tomain. - Pre-commit hooks for format and analyzer.
- ADO Repos + Wiki provisioned per Build Plan — Azure DevOps repos + wiki setup. No Boards / work-item usage — the build plan itself is the task list.
- Cross-platform from day one. MVP must validate on:
- Designer: Windows + macOS — single Tauri 2 + Angular codebase, desktop only. Host: Windows, macOS, iPad, Android tablet. (iPad / Android tablet authoring is Stretch, not MVP.)
- Client: iPhone, Android phone, iPad, Android tablet.
- Remote: iPhone, Android phone, iPad, Android tablet.
Networking and transfer
- In-process WebSocket server in the Host — see Tech Stack.
- Bonjour/mDNS service discovery — Host advertises, Designer and Client discover. See Tech Stack and Networking.
- Designer→Host
.quiztransfer over the local network — see Transfer Protocol. - Host UI gates incoming transfers with manual confirm per push — every incoming transfer prompts the operator before being accepted.
- Eager push: Host pushes the stripped package — Client-canvas content + Client resources for the entire quiz, minus every
question.answerKey— to each Client on join. Keys are stripped at package-load time into a session-side answer table; phones never hold answers and all marking is Host-side (Question Model — answer-key custody). Late-joining Clients see a progress UI and jump directly to the current slide on completion. - Host is authoritative on timer state; Clients render a local countdown that reconciles with the Host's tick. Time-up emits a "lock" message; late submissions are rejected unless the question's
lateSubmissionpolicy accepts them (full or with a penalty fraction within the configured window). The host operator can override the timer live (extend, skip, manually lock/unlock).
Schemas and the object-type plugin contract
- Quiz manifest, slide, canvas, and element schemas in the shared
schemas/contract — see Quiz Package Format. Every slide is a question or an interstitial, and question slides carry the first-class question node (questionType,prompt, type-specificanswerKey,scoring,timing,inputBinding) — see Question Model. - Round (slide-grouping) schema —
manifest.slideOrderis the only order authority;slide.roundIdis the only membership authority; rounds hold metadata + scoring defaults only. - WebSocket message envelope schema with extension hooks.
- Schema version field on quiz packages, validation on load.
- Object-type plugin contract per Object-Type Architecture: type contract + registry, Designer editor surface, Host runtime surface, Client runtime surface, optional protocol extension.
- Built-in registry per app — types register themselves on startup.
Question-first slide model
The quiz domain model is question-first — canonical record in Question Model:
- Every slide is a question or an interstitial. Question slides carry the first-class question node (prompt, type-specific answer key, scoring, timing, and a binding to exactly one input element on the Client canvas — validator-enforced); interstitials are presentation-only (no inputs, no scoring, no timing lock).
- "+ Add slide" opens the question-type picker (the 8 MVP question types + Blank (interstitial)) and scaffolds the slide from a per-type template; the slide inspector is question-first (prompt, answer-key editor, scoring, timing at the top).
- Host-only marking — answer keys never leave the Host (the eager push carries the stripped package); every mark returns via
scoreUpdate. - Built-in answer reveal — quizmaster cues Reveal answer (operator-window button + Remote command); the Host renders the question type's built-in answer presentation from the answer key on the audience window.
- Buzzer adjudication — first press locks all buzzers and fires the winning team's jingle; the quizmaster marks the verbal answer Correct / Incorrect from the operator window or the Remote; incorrect excludes the team (optional configurable penalty) and re-opens buzzers for the rest.
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:
- F-DE-28 / F-DE-29 / F-DE-30 — bundle buzzer jingles + premade-avatar set inside the
.quiz; Designer ships a small built-in default asset library. - F-CL-14 — captain takes a photo (camera) or picks from the bundled premade-avatar set.
- F-CL-15 — captain picks a buzzer jingle from the bundled set.
- F-HO-26 — Host renders the team photo on leaderboard rows + per-team callouts.
- F-HO-27 — Host plays the per-team buzzer jingle on
core.buzzerfirst-press wins.
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
- Automated end-to-end test: Designer authors a quiz question-first (with host-notes) → Designer pushes to Host over LAN → Host loads (stripping answer keys) → Clients join (stripped eager push) → quizmaster pairs a Remote → quizmaster advances slides from the Remote → answers marked Host-side and tallied → answer reveal cued → leaderboard finalises.
- Manual playthrough of a multi-round quiz (question slides + an interstitial + timer + leaderboard) on a real Wi-Fi network with the quizmaster walking the room with a paired Remote.
Explicitly not in scope
- Media: image, audio, video object types.
- Drawing input (Stretch).
- Marking queue + session results record / JSON export — Alpha.
- Mini-games and the mini-game framework.
- Animated reveals, transitions, big-reveal stings — visual/motion polish is Alpha and Beta. (The built-in answer reveal itself is MVP; custom author-styled reveal overlays are Beta.)
- Cross-cutting design language application — typography/palette/motion vocabulary per Design Specification is applied in Beta.
- Stylus support — touch only on every platform.
- Cloud authoring, accounts, version history.
- Any non-functional target requiring real-device perf measurement (those tighten in Alpha and Beta).
- Sign-in flows, app-store distribution, privacy policy, store listings — that's Production.
Acceptance criteria
- Every functional requirement listed under "In scope" passes its automated test.
- The end-to-end vertical slice test runs green in CI on Windows and macOS Designer + Host targets, and runs manually on iPad and Android tablet for Host, plus the four Client platforms and the four Remote platforms.
- A real pub-style quiz of ≥3 rounds, ≥15 slides total, with multiple-choice + free-text question slides and at least one interstitial, plus a timer and a leaderboard, can be authored question-first in the Designer (including host-notes per slide) and run end-to-end with two or more Client devices and a paired Remote driving advance/go-back.
- Every question type's built-in answer reveal renders on quizmaster cue, and a buzzer question can be adjudicated Correct / Incorrect from both the operator window and the Remote.
- The eager push verifiably carries no answer keys; all marking happens Host-side.
- The plugin contract is exercised by the full MVP cohort (8 question types + the display elements), and adding a new object type does not require core-code changes (validated by the first Alpha object type).
- No file-system, network, or schema operation depends on cloud or auth.
- The Remote app's minimum viable controls are functional: discovery, pairing, mirror, host-notes display, live state, advance/go-back, Reveal-answer cue, buzzer adjudication. The rich command set (F-RE-9 / F-HO-24) is Alpha — the WebSocket control message family must, however, leave room for it without core-code rework.
How success is measured
- Internal demo of the end-to-end slice on every supported platform.
- Time to author a 15-slide quiz from a blank Designer is under one focused sitting.
- A new object type can be added to the codebase by following the plugin contract alone, without touching slide or session code.
Alpha
Goal: Internal testing builds capable of hosting a full quiz. Every built-in object type is in place except the mini-game framework. The Remote app — already shipping in MVP with minimum viable controls — gains its rich control command set. Crash recovery becomes operational. Quality is "developer-acceptable" — the visual/motion polish, cross-cutting design language, and final brand treatment still come in Beta.
The scope shift from MVP is breadth and operational hardening: more object types, richer Remote controls, 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:
- Jump to a specific slide (random access, not just sequential).
- Trigger element reveals (e.g. show the leaderboard).
- Lock or unlock Client input.
- Extend or skip the timer.
- Override scoring per team.
- Drive the marking queue (F-HO-32) — per-team submission review with accept/reject + retroactive re-score. Live answer-correction (F-HO-29 — edit the question's answer key; covers the "we'll accept that synonym" / "I typoed the answer key" cases) is the same operation surfaced here, on both the Host operator window and the paired Remote.
- Remove a team from the session.
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):
- F-HO-32 — the marking queue: the operator window lists per-team submissions for the current question (free-text especially) with accept/reject toggles that re-score retroactively. This is the concrete surface for F-HO-29 live answer-correction — editing the answer key and re-running the marker are the same operation — and it generalises the MVP buzzer
adjudicatemessage. Available on the operator window and the paired Remote (F-RE-9). - F-HO-33 — the session results record + JSON export: a per-question record (team → submission, verdict, points, receive timestamp) kept inside the session snapshot, exportable as JSON (final standings + per-round + per-question tables) at session end. A local file the quizmaster owns — the seed for Stretch analytics without the privacy questions.
Designer additions for Alpha
- The object-type palette grows to include the Alpha cohort (image, audio-clip, video, image-reveal).
- The media-attachment plumbing (F-DE-8) becomes meaningfully exercised by
core.image,core.audio-clip,core.video. - Audio-clip slide trigger — the inspector exposes a
playTrigger(on slide entry / on quizmaster trigger / after delay) so the host can score a music question against a specific audio-clip cue. - Team-customisation asset bundling already ships in MVP alongside the
core.buzzerquestion type; Alpha only extends the default library if the Beta-tier polish pass needs it.
Crash recovery (operational hardening)
This is the load-bearing piece of Alpha — see Networking — Crash recovery and the new requirements:
- F-HO-18: Host persists session state to disk after every scoring event and every slide advance.
- F-HO-19: Host on launch with a saved session prompts to resume or start fresh; resume restores slide pointer, team list, scores, per-element state.
- F-CL-10: Client persists its team identity locally; reconnects to a recovered session as the same team.
The reconnection protocol path is the same one used for Wi-Fi-blip recovery — only difference is whether the Host's state was rebuilt from disk or in-memory. This means the Wi-Fi-recovery work in MVP and the crash-recovery work in Alpha are largely the same code path, with Alpha adding the on-disk snapshot.
Animation and reveal beats
- Animated reveals on Host slides (F-HO-13) — first pass, not yet polished to brand spec.
- Leaderboard reveal animations.
- Question-transition motion budget verified informally (formal target lands in Beta).
Reliability and performance
- Reliability soak test: client disconnect storms, host backgrounding, Wi-Fi flap, simulated Host crash + recovery.
- Performance: 60 fps animation budget verified on iPhone 12 / equivalent Android (measured in the exported
Quiz.Stageruntime build) per Non-Functional Requirements. - Element answer-submit round-trip latency < 200 ms on local Wi-Fi (verified).
- Remote control-message round-trip latency < 200 ms on local Wi-Fi (verified).
Documentation
- Object-type plugin contract is documented in Object-Type Contract (alongside the narrative Object-Type Architecture); Alpha verifies the contract is sufficient for the new cohort and folds any learnings back into that page.
- Remote control-message family is documented in Live Play Protocol; Alpha verifies the Alpha rich-command set is fully specified and exercised end-to-end.
Explicitly not in scope
- Mini-games and the mini-game framework — Beta.
- Brand-true motion language and full Design Specification treatment — Beta.
- App-store distribution, store listings, privacy policy — Production.
- iPad / Android tablet Designer authoring — Stretch.
- Stylus support — Stretch.
- Multi-Remote support — Stretch, if ever.
- Any cloud or auth.
Acceptance criteria
- A full live quiz spanning all Alpha object types can be authored, transferred, and run end-to-end without crashes.
- A team can join with a photo (or a premade avatar fallback) and a chosen buzzer jingle; the photo renders on the leaderboard and the jingle plays on the Host when that team wins a buzzer press.
- Reliability soak test passes its acceptance thresholds (no Host crash through a 90-minute session of disconnect storms).
- A simulated Host crash mid-session is recoverable: Host relaunches, the operator chooses "resume", Clients reconnect as the same teams with their scores intact.
- The Remote app, paired with the Host, can drive a full quiz session from the quizmaster's phone using the rich command set — including jumping to a specific slide, triggering a leaderboard reveal mid-slide, and overriding a scoring decision live (all of which build on the MVP advance/go-back + reveal/adjudicate baseline).
- A free-text submission rejected by the auto-marker can be accepted from the marking queue (operator window and Remote) with scores updating retroactively, and the session exports a JSON results file (final standings + per-round + per-question tables) at session end.
- Latency targets verified on at least one Client / Host pair and one Remote / Host pair on local Wi-Fi.
- Internal stakeholders (Quiz UK) play a full quiz session and the result is "this could become a product."
How success is measured
- Full internal playthrough — the team can run a quiz night using the platform without manual intervention beyond authoring.
- Bug count after a representative session converges to a fixable list, not a system-level rethink.
- The quizmaster's experience walking the room with the Remote feels right (no constant glances back at the Host).
Beta
Goal: A polished build that Quiz UK can run with real teams in a real venue. Mini-games are introduced. The platform looks and moves like the Design Specification describes, not like a developer build.
The scope shift from Alpha is polish + mini-games — the loud, performative parts of the experience that the brand identity promises.
In scope
Mini-game framework
core.mini-gameelement implementing the full plugin contract.- Mini-game framework as a separate concern within the Client and Host (lifecycle, scoring hand-off, presentation overlay).
- Six mini-games ship as built-ins, registered against the mini-game registry resolved by
core.mini-game— see Object Types — Mini-game built-ins for inspector-configurable fields per mini-game: mini-game.internal-clock— judge-the-time press; closer to a configurable target scores more, overshoot scores zero.mini-game.team-shoot— "Duel" — first-press-wins between a named pair (or the next-fastest team); wrong-team presses lose configurable points.mini-game.spin-wheel-modifier— post-answer scoring flourish; lands on a± valuesegment that applies to the configured target teams.mini-game.countdown— TV-show "Countdown" letter round. Letters drawn from a configured consonant/vowel mix; teams race to build the longest valid word in a configured window; scoring weighted by word length.mini-game.wheel-of-fortune— phrase hidden behind tile mask; teams guess letters in turn (or buzzer-race) to reveal letters; first team to solve the phrase scores.mini-game.wordsearch— grid rendered on the Host and Client; teams race to find target words. Scoring options: first-to-find each word, time-based per-word, or "find as many as you can in T".
Question-type cohort
Two additions land in Beta, each implementing the full plugin contract:
core.word-scramble— animated scrambled-letter tiles on the Host; buzzer-style first-press submission on the Client; first correct team scores; wrong-attempt penalty + per-team cooldown configurable.core.match-pairs— drag/connect pairing UX; multi-pair scoring per correct match.
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. 1×, 2×, 5×) 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:
- Author bundles a picture pool (≥ 25 images) inside the
.quiz. - Each Client renders a 5 × 5 bingo card seeded uniquely per team from the pool.
- Slide order is randomised per session so cards align with whichever picture is being shown.
- Teams tap a card cell when its picture comes up; a line / full house / pattern (author-configured) wins.
- Implemented as a quiz-manifest field + a Client surface, not as a standalone object type. Compatible with the otherwise-standard slide playback.
Cross-cutting design language
The full Design Specification is applied across Designer, Host, Client, and Remote:
- Brand palette and gradients.
- Typography per the Design Specification.
- Motion vocabulary — confident/snappy spring motion, big-reveal beats, mascot animation.
- Iconography, layout, spacing scale.
- Voice and tone in copy.
This includes Quiz UK validation — the mascot, branding, and final brand name are confirmed and applied.
Theme system
Two layers of theming, both Beta:
- Designer chrome theme — author preference, light or dark, persisted in app settings (F-DE-25). Authors who spend long sessions in the Designer get a calmer light option.
- Per-quiz theme on Host / Client / Remote — the quiz manifest declares a theme that the playback apps render against (F-DE-26, F-X-4). Default is the dark brand theme.
The two layers are independent. Richer per-quiz custom palettes are Stretch.
Sound design
Full audio language ships in Beta (F-X-5): branded stings (correct / incorrect / lock / time-up / big reveal / end of round / end of quiz), transition motifs, and an optional ambient music bed. Audio is mixed for the venue — stings cut through pub ambient noise without being intrusive. Licensing/sourcing decisions belong to Beta-phase work.
Mascot animation rig
The mascot is rigged once (a single 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
- All target platforms tested on real hardware. Test devices needed: a modern iPhone, an old iPhone, a modern iPad, an old iPad, a modern Android phone, an old Android phone, a modern Android tablet, an old Android tablet. Older devices catch performance regressions on lower-end hardware that the dev workstation hides.
- Performance and latency targets re-verified on the older devices, not just the developer workstation.
- Hardware/OS minimum versions confirmed against Godot 4.x's platform support matrix (Host / Client / Preview — the
Quiz.Stageexport presets), Tauri 2's supported-platform matrix (Designer desktop, Remote mobile), and against real-device behaviour. The provisional minimums in Non-Functional Requirements become final at the end of Beta.
Quiz UK pilot
- Quiz UK runs at least one real live quiz night using the Beta build.
- Feedback loop captured (open-questions, bug list, feature requests).
Explicitly not in scope
- App-store distribution, store listings, privacy policy — Production.
- Sign in with Apple — Production, only if iOS App Store distribution is chosen.
- iPad / Android tablet Designer authoring — Stretch.
- Cloud-backed authoring, accounts, libraries — Stretch.
- Stylus support — Stretch.
- The remaining Stretch-tier question type (hotspot) — Stretch.
- AI-aided quiz authoring, broadcast view, recurring teams, tournaments, templates, question bank — Stretch.
Acceptance criteria
- A real Quiz UK pub night runs successfully on the Beta build.
- The Designer, Host, Client, and Remote each visually match the Design Specification on every supported platform (cross-platform polish is part of Beta, not deferred to per-platform).
- Mini-games run in-session without breaking the per-slide round-trip latency budget.
- Performance budget (60 fps; transition < 500 ms; round-trip < 200 ms) holds on the older test devices.
How success is measured
- Quiz UK is willing to run more nights with the Beta build.
- Real participants don't notice it's a beta.
Production
Goal: A launch build distributed through real channels to real users — quizmasters beyond Quiz UK. Polish is locked; the remaining work is the commercial and operational layer.
The scope shift from Beta is launch readiness: distribution, identity, store listings, privacy, and operational posture.
In scope
Distribution
Confirmed distribution channels:
- iOS App Store — required for any iPhone/iPad distribution. Triggers Sign in with Apple obligation only when cloud auth ships in Stretch; v1 has no auth.
- Google Play Store — required for any mainstream Android distribution.
- Microsoft Store — primary discoverability channel for Windows. Sideloaded
.exe/.msimay be considered later for power users. - macOS App Store / direct DMG — both. App Store buys discoverability and one-click install; direct DMG is the lower-friction path for users who prefer to download from the project site.
Each channel adds work for: signing, packaging, submission flow, and update mechanism.
Identity and policy
- Sign in with Apple wired up if iOS App Store cloud-backed auth (a Stretch goal) ships first. Apple requires Sign in with Apple only when other social-auth providers are offered, so it's only relevant when cloud auth lands. v1 ships no auth on any app.
- Privacy policy drafted and published.
- Store listings drafted (descriptions, screenshots, age rating, accessibility statements).
Hardware and OS minimums
The provisional minimums in Non-Functional Requirements are finalised in Beta, not Production. By Production they are locked. Production work is to ensure each store listing accurately reflects the locked minimums and that the app refuses to run (with a clear message) below them.
Operational
- Performance and latency targets re-verified on the production builds, not just the development builds.
- Crash reporting / telemetry decisions made (consistent with the privacy posture in v1 — see Non-Functional Requirements).
Explicitly not in scope
- Cloud-backed authoring, accounts, libraries — Stretch.
- Bundle-supplied object types — Stretch.
- Internet-based live play — Stretch.
- iPad / Android tablet Designer authoring — Stretch.
- Anything in the Stretch list.
Acceptance criteria
- A quizmaster who is not Quiz UK can install, author, and run a quiz on every supported channel (iOS App Store, Google Play, Microsoft Store, macOS App Store, macOS DMG).
- All store reviews / approvals pass.
- Performance and latency targets hold on the production builds, not just the development builds.
- Privacy policy and store listings are live and accurate.
- The app refuses to install or shows a clear "device not supported" message on devices below the locked OS minimums.
How success is measured
- Real third-party quizmasters (beyond Quiz UK) successfully run quiz nights using the production build.
- Crash-free session rate above an agreed threshold across the supported devices.
Process
Project Management
The Build Plan is the single source of truth for tasks. Each - [ ] bullet is a unit of work; ticking it to - [x] marks completion. There is no external work-item tracker mirroring the build plan. The build plan is split into one file per app plus a cross-cutting project file.
Azure DevOps still hosts the code repository, the CI / CD pipelines, and the auto-mirrored wiki — but ADO Boards / Stories / Tasks are not used.
Workflow from PRD sign-off to delivery
- PRD sign-off. The PRD is the locked-in requirements artefact, generated from the knowledgebase by the
docsskill. Sign-off ends "what are we building" debate; subsequent changes go through a controlled change process (re-edit the knowledgebase, regenerate, re-sign-off). - Work the build plan. Pick the next unticked bullet that has no unmet dependency, implement it under TDD per Testing, tick it
[x]on completion. - Task-completion loop fires. Whenever a chunk flips one or more bullets, the CLAUDE.md task-completion workflow runs: commit + push the chunk, regenerate docs, commit + push the regenerated HTML, publish the docs site.
- Iterate. No sprint ceremonies, no standup, no estimation. Phase tags on each section (
[MVP]/[Alpha]/[Beta]/[Production]) define delivery cadence; the build plan is worked top-down within each phase.
Adding work
New scope is added by editing the build plan in place — append a - [ ] bullet under the right section, or add a new ## section if the work is structurally new. Regenerate the docs site after the edit so the published task list stays current.
If the work changes the spec (not just implementation), update the knowledgebase first per the knowledgebase skill conventions, then add the build-plan bullets that implement the change.
Definition of Done
Every bullet meets the build plan's preamble Definition of Done before the box is ticked:
- TDD followed (failing test first, then implementation, then refactor) per Testing.
- End-to-end automated test exists for any user-facing feature the bullet touches.
- Lint and format pass; coding standards followed.
- UI changes additionally pass visual verification against the matching mockup — see CLAUDE.md — UI work — visual verification before sign-off.
Reference
Open Questions
Decisions deferred until more is known. Each entry says what is open and the reason it is being deferred.
Each entry is tagged with its status:
- Prototype-deferred — answer comes out of the load-bearing prototype work. No user-input decision needed today.
- Phase-deferred — answer is committed to a future phase (most often Beta or Production) where the work belongs.
- Open — needs a decision, currently waiting on external input or a deliberate timing.
-
Bundle-supplied object types (Phase-deferred — Stretch). v1 ships built-ins-only;
.quizpackages contain no runtime code. A future stretch goal — alongside cloud-backed authoring — is to allow object types bundled inside a.quizpackage 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. -
Internet-based live play relay (Phase-deferred — Stretch). Cloudflare Durable Objects are the leading candidate but the protocol design and discovery mechanism are not yet specified. Tracked in Stretch.
-
Cross-quiz analytics (Phase-deferred — Stretch). Whether and how to record quiz session data for the author's later review (e.g. "which slides were hardest?"). Privacy posture would need to be designed alongside. Tracked in Stretch.
-
Final brand and product name (Phase-deferred — Beta). The apps are referred to by their engineering 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.
-
Web Designer hosting model (Phase-deferred — Stretch). The Web-based Designer is a future-release Stretch. Hosting model — static CDN bundle vs Angular SSR — is decided when the Stretch is promoted. The Angular workspace itself is shell-agnostic behind the
PlatformAdapterinterface, so the port lands aBrowserPlatformAdapterplus the chosen hosting target rather than a frontend rewrite. -
Server-backed persistence vendor (Phase-deferred — Stretch). The Designer's
PersistenceService/LibraryService/TransferServiceAngular services abstract over aPlatformAdapterso the Tauri shell binds local-disk + mDNS implementations today. The eventual Web-based Designer Stretch lands HTTP-backed implementations against a server backend; the backing vendor (Postgres + S3-compatible vs platform-specific managed services like Cosmos / Blob Storage, or Supabase / Firebase) is deferred until the Stretch is promoted and the cloud-hosting target is picked. -
Eliminator final-reveal trigger (Resolved — Question Model, June 2026). Resolved by the Question Model — mechanics:
core.eliminator's answer key carries its owneliminationSchedule: [{ atMs, removeOptionIds }]for the staged drops (or quizmaster cue), and the final-reveal beat is quizmaster-cued — neither reuses the slide'sreveal.triggerfield. -
First-letter input UX (Partially resolved — Question Model, June 2026). Marking + interaction are pinned in the Question Model — mechanics:
core.first-letteris a single-pick A–Z grid (3-row layout default), auto-marked againstanswerKey.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. -
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.
-
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. -
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.
-
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
adjudicatemessage — editingquestion.answerKeyand 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. -
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
licenseGatefield. 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?". -
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 aSlideRailDragControllerfor the hand-rolled round-header drag + hit-testing),selection-chrome.component.ts(~860 — extract scale/rotate gesture controller + move geometry helpers intolib/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. -
AuthoringSession ephemeral-preview API (Open — design decision).
selection-chrome.component.tswrites live scale/rotate previews via rawsession.setActiveQuizat 60 Hz, committing throughSetRectTransformCommandon pointerup. Undo semantics are correct but the mechanism is a documented command-bus bypass in a component. Decide: giveAuthoringSessionan explicit preview-overlay API (thedragOffsetoverlay patternCanvasDragServicealready uses) so live gestures never touch the undoable document. Resolved when the next canvas-gesture chunk lands. -
Generated
anyleak from schema codegen (Open — codegen config). quicktype emitsexport type Data = any[] | … | { [key: string]: any }for the elementdatablob (src/app/generated/{canvas,element,slide}.ts), leakinganyinto every consumer and forcingas unknown ascasts (e.g.layout.service.ts). Fix at the codegen level — emitunknowninstead — likely alongside the planned Element components refactor that retiresobjectTypeData. -
Rust IPC contract tests use service-surface, not
tauri::test::mock_app(Open — testing convention). Thesrc-tauri/tests/*contract suite drives every command's service layer + pins the JSON envelopes, but does not bootmock_app+get_ipc_response(most commands need a window builder that mock_app scaffolding doesn't provide cheaply). Documented intests/error.rs. Revisit if a regression ever slips through the command-shim layer itself. -
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 restrictedcom.apple.developer.networking.multicastentitlement, 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. -
Godot
CameraServermaturity on Android / iOS (Prototype-deferred). Team-photo capture at join (F-CL-14) plans to use Godot'sCameraServer, whose maturity on mobile — especially Android — is unproven. Prototype the capture → crop → downscale → JPEG pipeline early in the Client chunk; ifCameraServerfalls short on either platform, fall back to a small native camera plugin behind the same capture seam. See Tech Stack. -
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 thecore.buzzerend-to-end chunk. -
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. -
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.
-
core.image-revealquestion-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 aQuiz.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 ModelinputBinding.type == questionTypeinvariant the export validator (quiz-validation.ts) enforces; (c) the legacycore.image-revealblock 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 ofcorrectOptionId) before authoring the Designer half — building it now would invent spec rather than implement it. -
Tiebreak ranking semantics (Open — confirm with the leaderboard chunk).
Quiz.Stage/core/scoring/tiebreak_resolver.gdimplementsscoring.tiebreakper 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 (fastestbreaks by Host receive time;sharedkeeps competition-style tied ranks;host-decidesflags 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 thecore.leaderboardreveal + the host-decides operator UI land; if an award-pool/split notion is actually wanted, the resolver gains a points-adjustment pass. -
Stage mDNS wire codec + Bonjour interop (Open — lands with the Host/Client chunk + real-device testing).
Quiz.Stage/core/discovery/service_advertisement.gddefines 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 + thePacketPeerUDPmulticast socket (224.0.0.251:5353) are app-layer and deferred: the pure-GDScript advertiser must emit real mDNS records for the Designer/Remotemdns-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 themdns-sdbrowse 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. -
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 StageCoreText. -
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'sFontImportService+FontRegistryService(@font-faceinstall frommanifest.fonts) are retained but currently unwired to any element — visible debt, not silent: they exist for the eventual theme-level model where a.quizpackages 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:
BrowserPlatformAdapter— implements the existingPlatformAdapterinterface against browser APIs. File System Access API for local-disk save / load where supported; fall-back upload / download elsewhere. No mDNS — Host discovery is manual IP entry or host-issued QR / pairing code. No subprocess spawn —Run from slidefalls back to pushing the in-memory.quiz+ a start-slide control envelope to an already-running Host instance.- Hosting target — Angular bundle served as a static site behind a CDN, or via Angular SSR if first-paint matters. Pick at promotion.
- Auth — OAuth → JWT, multi-tenant identity.
- Persistence backend — server-backed
PersistenceService(Postgres for manifests + S3-compatible blob store for resources). - Library backend — per-account
LibraryServicebacked by the same blob store; the local-disk dedup-by-hash semantics adapt cleanly. - Transfer backend — relay-routed transfer for cross-network play; LAN push uses host-issued pairing codes since browsers cannot do mDNS.
- Stage preview CDN delivery — the same
Quiz.Stageweb-export bundle the Tauri Designer embeds today, served from CDN with correct CORS + brotli/gzip + lazy-load until the preview pane opens. - Routing + auth gates — login → quiz library → editor.
Code reuse from the Tauri Designer:
- The Angular workspace itself — every authoring component runs unchanged because the WebView already runs web-tech.
AuthoringSession,CommandDispatcher, the service layer — same TypeScript code; promotion swaps thePlatformAdapterimpl.schemas/codegen — TypeScript types +ajvvalidators shared across both shells.
The reuse story is the load-bearing reason to keep PlatformAdapter and friends in place even though v1 only ever ships the Tauri impl — it keeps the eventual web port additive rather than a rewrite.
Open questions for promotion (see Open Questions):
- Hosting model (static CDN bundle vs Angular SSR) and backend vendor (Postgres + S3-compatible vs platform-specific managed services).
- Browser-shell discovery UX — manual IP entry vs host-issued QR / pairing code as the primary path.
Run from slideUX on web — companion launcher tray app vs "install the Tauri Designer when you need it" vs always-push to a running Host. Likely the last.
Risks specific to this stretch:
- Cloud infrastructure scope — Postgres, k8s / managed runtime, CDN, ops on-call. Material commitment; gates the stretch promotion.
- Auth / multi-tenant SaaS plumbing — OAuth → JWT, RLS or app-layer tenant isolation.
- Real-time collab (optional) — CRDT layer (e.g. Yjs) if multi-user editing is required. Defer unless asked for.
- Web-export bundle size in browser — tens of MB initial download; CDN + brotli/gzip + lazy-load until preview pane opens.
- Asset bundle CORS / signed URLs — backend serves them with correct headers and per-tenant access controls.
- Browser compat — the Godot web export (WebGL 2 / Compatibility renderer) is safest on Chromium; Firefox / Safari work with caveats.
- File System Access API browser support — strong on Chromium, missing on Safari; design fall-back to upload/download for Safari users.
Spikes (when stretch is promoted):
BrowserPlatformAdapter— File System Access API integration; QR / pairing-code Host discovery flow; verify the Angular workspace runs unchanged inside a plain browser tab.- Backend skeleton — API + Postgres + CDN + CI deploy.
- Server-backed
PersistenceServiceandLibraryService— replace the Tauri impls with HTTP-backed ones. - Auth + multi-tenant story — OAuth login, per-tenant
.quizstorage, isolation enforced. Quiz.Stageweb 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.
- Provision the cloud backend (vendor TBD).
- Account / profile + quiz library data store with per-tenant isolation enforced (RLS or equivalent).
- Quiz blob storage with per-owner isolation.
- Version history with pruning policy (latest N + pinned).
- Sign-in on Designer (F-DE-1) and Host (F-HO-1) per Authentication.
- Designer: save quiz to cloud, version history, restore (F-DE-15).
- Designer: soft-delete + trash view (F-DE-16).
- Designer: pin a version to protect from pruning.
- Host: list quizzes from the cloud library, mark which are downloaded (F-HO-2).
- Host: download and cache a quiz package from the cloud (F-HO-2).
Bundle-supplied object types
- Loader for object types bundled inside a
.quiz(signing, sandboxing, install UX) — see OQ#1. - Author-facing UX for installing third-party object types.
This is gated on cloud-backed authoring landing first because the trust / distribution / install model only really makes sense in a cloud-aware world.
Stylus support
- Apple Pencil pressure / tilt / palm rejection on iPad.
- S Pen, Surface Pen, Wacom on Android / Windows tablets — Godot's pen-aware pointer events (pressure / tilt) on the Client; browser Pointer Events on the Angular surfaces.
- Applies to: Designer authoring sketches, embedded preview, the
core.drawingquestion type on the Client.
v1 is touch-only on every platform.
iPad / Android tablet Designer
The Designer ships first-class desktop only in v1 (Windows + macOS). iPad and Android tablet authoring is deferred to Stretch and ships derived from the Web-based Designer codebase — same Angular application wrapped as a mobile app shell (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:
- Demo mode — the Host runs any
.quizfor free but caps Client devices at 2–3 and renders a persistentDEMO MODEwatermark on the Host canvas. Useful for rehearsals + previews; never used in a paying venue. - Live mode — a one-shot credit unlocks an unwatermarked session for 24 hours. Activating "Go Live" on the Host consumes one credit (the credit is held against the Host's licensed account, not the
.quiz). Quizmasters buy credits through the portal. - Licensing applies only to the Designer + Host. Client + Remote are unauthenticated by design.
- License keys + credit redemption are validated against the portal when online; the Host caches a signed entitlement for offline-during-quiz use.
Quiz Store:
- Curated catalogue of premade quizzes by selected authors (us to start; third-party authors later).
- Authors upload
.quizpackages through the portal; the store handles distribution + per-pack revenue share. - Customer-side: browse, buy, download into the Host's local library.
- Per-account analytics — quiz runs, team count distribution, popular slides.
Architecture implications (consider during v1):
- The Designer + Host already need a settings surface for account credentials; design that surface so a "Sign in / manage credits" pane drops in without rewiring.
- The Host operator window already includes session HUD chrome; a "Live mode — Xh remaining" pill drops into that chrome cleanly.
- The
.quizmanifest already declares object-type versions; reserve alicenseGatefield so future packs can be marked store-only without a schema break. - The portal sits behind the cloud-backed authoring stretch — same auth + tenancy stack, different product surface. Both promote together when cloud is committed.
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:
- Client —
client.screen.code-entrypanel with a chunked code input (Q7-3K9-FX), reachable from the discover screen as an alternative to picking a Host from the Bonjour list. See Client surfaces. - Host —
host.idle.session-codeandhost.join.session-codesurfaces displaying the relay-issued code alongside the LAN QR. See Host surfaces. - Remote — existing pair-method picker (Remote surfaces — §2) gains an internet branch the same way the Client discover screen does.
See Networking — Internet-based play and OQ#2 for the open relay-protocol design questions.
Better Host crash recovery
Crash recovery is in v1 (Alpha phase). Stretch ambition: faster snapshot cadence (per-message rather than per-event), fully resumable timer state including elapsed time during a Host outage, multi-instance Host failover. None of these are required for a workable v1.
Additional question types
The v1 object-type catalogue ships 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:
- Cloud auth (the Quizmaster's account anchors the league of recurring teams).
- A team-identity store keyed by quizmaster + venue.
- Team-claim UX on Client join: "Are you returning team X?"
Privacy and data-protection requirements need design alongside — see OQ#3 for the analogous concern with cross-quiz analytics.
Tournaments / leagues
A series of quiz sessions feeding a season-long aggregate leaderboard. Distinct from a single quiz: needs season schema, cross-session scoring rules, league standings UI on the Designer/Host. Depends on recurring teams and cloud-backed authoring landing first.
Quiz templates / starter packs
A library of pre-built quizzes the author can clone and adapt. Lowers the barrier to a first-night quiz — start from "Live Quiz Classic" rather than a blank slide.
Depends on cloud-backed authoring (templates live in the cloud library; cloning makes a private copy for the author to edit).
Question bank / reusable questions
The author's personal library of questions, decoupled from any specific quiz. A question can be authored once and pulled into any number of quizzes. Each question carries its own answer, scoring rule, and metadata.
Depends on cloud-backed authoring (question bank lives in the cloud library, not a single .quiz package).
Speed-round mode
A round-level config that runs slides back-to-back with very short timers and no inter-slide pause. Different cadence to the default per-slide pacing — pressure-format rounds.
Could be promoted to Alpha as a small addition to the round-level timing schema rather than waiting for Stretch — it's mostly configuration on top of existing timer and round primitives. Logged here so it isn't lost.