Skip to content

Latest commit

Β 

History

History
500 lines (396 loc) Β· 27.7 KB

File metadata and controls

500 lines (396 loc) Β· 27.7 KB

Quackster β€” Runtime Architecture

Status: design. The data layer (content load/validate/query/board) is implemented in Rust (api/src/data/); the game runtime described here is not built yet. Scope: how the backend serves content and drives live multiplayer game sessions. Content shape is in data-model.md; UX flow is in game-flow.md. This doc is the canonical reference for backend ownership, transport, concurrency, and the game-session model.

Stack shift (supersedes AGENTS.md)

AGENTS.md describes a SvelteKit-server architecture (remote functions, valibot, TS data layer). That is superseded. The current architecture:

  • Rust + axum backend (api/) owns everything server-side: content loading, validation, pool query, board building, and the live game runtime.
  • SvelteKit is a pure static frontend β€” built with adapter-static, served by axum as static files (APP_STATIC_DIR, defaults to ../build in dev). No SvelteKit server, no SSR, no remote functions.
  • ts-rs exports Rust types to TypeScript. Rust is the single source of truth for all shared types (content + wire protocol); the frontend imports generated .ts.

Offline-capable

The core game loop must run with no internet after setup β€” a moderator self-hosts at home (binary + content + local media) and plays on a LAN or single box. Internet-only features (youtube:/url: media, OTLP telemetry, a future LLM judge) are opt-in and gracefully absent offline, never a hard dependency for core play. Keep the core play path free of outbound calls. See docs/decisions/0003-offline-capable-self-host.md.

The legacy TS data layer (src/lib/server/data/) and its schemas (src/lib/schemas/) have been removed β€” the Rust port in api/src/data/ reached parity and is now the sole data layer.

Why Rust owns the data layer

Single owner eliminates the two-validator desync the data model fights against (data-model.md: "no desync possible"). Rust loads YAML once (serde_yaml + garde validation), exposes types via ts-rs, and the frontend consumes generated types. One schema, one validator, one truth.

Frontend ↔ backend contract

Two transports, split by temperature of the data:

  • REST for cold content β€” questions, packs, boards, gamemode manifests. Cacheable, stateless reads. Served under /api/*.
  • WebSocket for hot game state β€” buzzes, answers, scores, timer, phase. One WS connection per client into a live room (axum ws feature).

ts-rs exports both the REST DTOs and the WS message types, so the Svelte client is fully typed against the Rust definitions.

Game runtime β€” concurrency model

Actor-per-room

Each live game room is one owning tokio task holding the room's mutable GameState. Rooms are fully isolated β€” one room's task hanging or panicking cannot touch another's state. No shared lock on the hot path.

  • mpsc (manyβ†’one): players β†’ room. Every socket in a room holds a clone of the room's mpsc::Sender. A player action (Buzz, Answer, …) is sent as a Command into the channel. The room task is the single receiver, pulling commands one at a time β€” input is serialized, so there are no data races on GameState and first-buzz-wins falls out of channel ordering (no explicit race handling).
  • One broadcast (oneβ†’many): room β†’ players. The room task holds a broadcast::Sender<GameState>; every socket holds a Receiver. On each state change the room broadcasts the full-truth GameState once; all sockets receive a copy.
  • Room task loop is a tokio::select! racing: next Command from the mpsc, the next timer deadline (sleep_until), and a shutdown signal. Whichever fires first mutates state, broadcasts, loops.
players --mpsc.send(Command)--> [ROOM TASK owns GameState] --broadcast(GameState)--> sockets
                                  loop { select! { cmd | timer | quit } }    each socket: project(state, grants) -> ws.send

Room registry & lifecycle

A central Arc<DashMap<JoinCode, RoomHandle>> lives in AppState. RoomHandle holds the room's mpsc::Sender and broadcast::Sender (subscribe for a receiver). The map is touched only on create / join / destroy β€” never on the hot buzz/timer path.

  • Create β†’ spawn room task, insert handle.
  • Join β†’ look up by join code, clone the mpsc sender, subscribe to broadcast.
  • Destroy β†’ the room task owns its own death (empty-room timeout via the same select!) and removes its own entry on exit. No central reaper, no orphan tasks.

Media fetch as a third RoomMessage source

The room actor's mpsc carries RoomMessage from any source β€” clients, the join handshake, and the media fetcher. At room spawn the actor collects every youtube: ref on the board into GameState.media_status (Pending), hands the board's refs + a clone of its command_tx to MediaFetcher::prefetch. The fetcher task runs tokio::spawn and sends RoomMessage::MediaStatus { ref, status } per transition (Downloading β†’ Ready / Failed(stderr)). The actor updates the map and the broadcast carries the new status to all clients β€” same single-owner rule, three independent sources. Cached files short-circuit to Ready immediately (no Downloading ever emitted). Mod retries via Command::RetryMediaFetch (collected inline in the room loop, not through state.apply, because it needs the fetcher + command_tx); the actor flips failed entries to Pending and re-kicks the fetcher. media_status is projected to Moderate only β€” players never see it. No StartGame gate; the mod's client renders a confirm dialog from data it already has.

Persistence

None in v1. Rooms are pure in-memory tokio tasks + channels. A server restart drops all live games β€” acceptable: a quiz session is minutes long and ephemeral, like Kahoot. The only durable state is content (YAML on disk).

ponytail: rooms in-memory only; add SQLite snapshot+restore when crash-recovery matters. When added: the room task is already the single owner of its state, so it can snapshot per state-transition without locks, and boot restores the DashMap from rows. Persist timer deadline timestamps, not remaining seconds, so clocks survive wall-clock gaps.

When it lands, a room is one opaque JSON blob β€” gamestate is never normalized into tables β€” and an undecodable blob is a dropped room, not a migration (strict serde makes every shape change fail loudly). Durable history/stats share the same DB under the opposite policy, and the two datasets meet at exactly one call: persist_history(&GameState) on game end, projecting judgment_log into relational rows, then deleting the room. SQL models results, not gamestate.

The full design for durable persistence and a highly available multi-pod Kubernetes deployment (a Store trait β€” SQLite self-host / Postgres cluster β€” plus room-affinity registry, redirect routing, and lease/fence failover) is recorded in docs/decisions/0004-persistence-and-horizontal-scaling.md. v1 code stays in-memory until the SQLite phase is built.

Room identity & joining

  • One identifier: a 6-char join code (e.g. A1B2C3). It is the registry key and the room's identity for its whole life. Regenerated on live collision. No separate UUID β€” a dead room's code lookup simply fails, which is correct. ponytail: single join-code identity; add a stable opaque id only if reconnect tokens or persistence need it.
  • Creation gate β€” server-level secret in config/env. Controls whether anyone may spin up a game on this instance. Open if unset.
  • Join gate β€” optional per-game password, set by the host in the UI at creation. Empty = the code is the only secret.
  • Capacity cap β€” joins rejected when full. Host can lock the room after start (no late joins) β€” a quiz mid-question can't cleanly absorb new players.

ponytail: join code is guessable; add entry throttling when the instance is public.

Reconnection

Phones sleep and wifi blips mid-quiz, so reconnection is required, not optional.

  • On join the server issues an opaque reconnect token; the client stores it in localStorage.
  • A dropped socket detaches but the player stays present-but-disconnected β€” slot, score, and grants persist on the room, keyed by token.
  • The next socket presenting that token reattaches to the exact slot. Survives a page refresh too. Join and reconnect also send the client's normalized BCP 47 locale; reconnect replaces the slot's stale locale.
  • SetLocale changes the reconnectable slot's locale during play and triggers a fresh projection without changing grants or public player data.
  • The player needs a stable token even though the room does not need a stable id β€” different concerns, no conflict with the single-join-code decision.

Wire protocol & per-role projection

Tagged-enum envelopes

  • Client β†’ server: a Command enum (Join, Buzz, Answer, Next, StartGame, Grant, ExtendTimer, …).
  • Server β†’ client: a ServerMsg enum (Snapshot(ClientView), Joined, Error, …).

serde #[serde(tag = "kind")] β†’ clean discriminated unions in TS via ts-rs.

Full snapshot, not deltas

Each broadcast carries the complete current view, not a diff. A quiz state is small (players, scores, current question, timer deadline, phase), so the client is a pure function render(view) β€” killing an entire class of client/server delta-desync bugs (same spirit as the data model's "no desync"). ponytail: full-snapshot broadcast; add deltas only if a room's state outgrows a WS frame.

Capabilities, not fixed roles

There is no single game state to send β€” a phone player, a presenter screen, and a moderator each see different things, and roles compose (a moderator can also play). Modeled as grants, not roles:

  • Play β€” occupy a slot, buzz, submit answers, own a score.
  • Present β€” see question / options / scoreboard / timer (the big-screen view).
  • Moderate β€” advance the game, manage players, see correct answers, controls.

A connection holds a grant set. Named roles are grant bundles that compose by union: "player" = {Play}, "presenter" = {Present}, "host" = {Moderate, Present}, "playing moderator" = {Moderate, Present, Play}.

Projection is the trust boundary

The room broadcasts the full-truth GameState on one channel. Each socket runs one function project(&data, &state, &viewer_slot) -> ClientView before ws.send. ClientView is a single ts-rs-exported type with optional sections (question?, buzzer?, answer_input?, correct_answer?, controls?, scoreboard?); project fills each section only if the grants permit it.

  • The correct answer is stripped server-side before it reaches a player socket β€” never sent-then-hidden in the client. project is the single gate.
  • This is the one place security is not simplified away: project carries a test asserting a {Play}-only view never contains correct_answer.
  • Composition is free: a playing-moderator's ClientView simply has buzzer + controls + correct_answer populated. One channel, one project, any grant combination β€” no per-role channels (which would explode combinatorially for composite roles).

ponytail: full-truth broadcast + one viewer-slot projection; one channel, grants compose freely.

Per-player content locale

Locale lives on the reconnectable PlayerSlot, not the public PlayerView. Projection resolves every translatable live field independently through the most-specific overlay chain, for example zh-Hant-TW -> zh-Hant -> zh -> canonical. Locale matching is case-insensitive; normalization lowercases language, title-cases script, and uppercases region. Choice and order entries merge by stable ID. Media and accepted-answer lists replace as units, so an explicit empty list remains meaningful.

Live localization covers question prompt, explanation, choices, order items, media, and grid category names. Joining, reconnecting, or changing locale prefetches only localized youtube: media reachable from that board and locale chain; it does not eagerly download every translation.

A submission records the effective player locale. Later language changes cannot reinterpret judgment history. Moderator projection keeps the question in the moderator's locale but shows accepted answers in the answering player's captured locale plus canonical accepted answers when they differ. The future Auto judge must accept the union of those localized and canonical answers through the same resolver.

Granting & authorization (least privilege)

  • The creator of a room gets Moderate (+ Present, optionally Play), bound to their reconnect token. Creating requires the server creation secret, so Moderate is transitively gated by config/env.
  • Every joiner gets exactly {Play} β€” no requesting, no choosing. Just the join code (+ password if set).
  • All other grants (Present, Moderate, revoking Play) are assigned after join by an existing moderator via a Command::Grant { token, grants } that only moderators may send. Server-enforced.
  • The client never asserts its own grants. It sends a token; the server stores grants per token and looks them up. Devtools can forge a Command but not a grant. Reconnect restores the slot's stored grants.

A presenter (big screen) therefore also joins as {Play} first, then a moderator flips it to {Present} β€” one join path, least privilege, no self-claim surface.

Gamemode model

The data layer is gamemode-agnostic (pool query + board builder) and already holds two modes: grid_quiz (Jeopardy-style board) and linear (Kahoot-style list) β€” GameEntry in data/types/game.rs.

The runtime mirrors that from the start: GameState.mode is a ModeState enum (GridQuiz(GridQuizState) | Linear(LinearState) | …). Each mode's play state lives in its own variant; common state (players, scores, timers, grants) stays on GameState.

What stays hardcoded per mode is behavior β€” buzz/lockout/timer/scoring policy in the room task. Extract a Gamemode trait only once two modes have actual runtime behavior and the seams are felt: data shape β‰  behavior shape, and no mode runs yet. Designing the trait first would guess seams.

ponytail: ModeState data enum now (two modes exist); behavior Gamemode trait deferred until two modes have runtime behavior.

Buzz / order policy

A policy axis owned by the gamemode (its default) and overridable by the moderator per game:

  • open-floor first-buzz β€” question goes live, any Play grant may buzz, the first buzz to arrive wins the floor (channel-ordered).
  • turn-order β€” players take turns; on a failed question the floor opens to everyone except those already wrong.

Lockout is orthogonal to the initial-floor policy: a wrong answer locks that player out and reopens the floor to the rest, until correct / timeout / all locked out. Same lockout mechanism under both policies.

ponytail: open-floor first-buzz + lockout-on-wrong hardcoded for grid_quiz; buzz/order/timer policy moves into the Gamemode trait when a second mode gains runtime behavior.

Side note β€” rotating announcer

A future variant: the read-aloud Present grant rotates round-robin per question; only the current announcer reads, and stops on buzz. No new machinery β€” just reassign Present per question on the existing capability model. ponytail: build when a gamemode wants it.

Grid quiz runtime (state machine)

Concrete play model for grid_quiz. ModeState::GridQuiz(GridQuizState) holds the DYNAMIC per-round state; STATIC rules are read from the data-layer Rules on the game config (single source of truth β€” not duplicated here). The chain-spanning state β€” current_game_idx and the global judgment_log (score = fold(log)) β€” lives on GameState, not per-mode (see Β§Adjudication).

GridQuizState {
    phase: GridQuizPhase,
    active_picker: Option<Token>,   // whose turn to pick a cell
    floored_player: Option<Token>,  // None = buzz open; Some = answering
    locked_out: HashSet<Token>,     // wrong this question, barred from re-buzz
    current: Option<CurrentCell>,   // cell + question in play; None on the board
    used_cells: HashSet<(usize, usize)>,
    picker_rotation: VecDeque<Token>,
}
enum GridQuizPhase { Lobby, BoardSelect, QuestionOpen, Reveal, GameOver }

Phases & transitions

Lobby       --StartGame-->        BoardSelect  (shuffle -> picker_rotation; first = active_picker)
BoardSelect --PickCell{x,y}-->    QuestionOpen (mark used, resolve question; open buzz,
                                  or floored = active_picker when buzz policy is turn-order)
QuestionOpen --Buzz (first)-->    floored set, answer-timer deadline starts
QuestionOpen --Answer / judge-->
    correct | all locked | timeout --> Reveal
    wrong, others remain           --> reopen buzz, +locked_out (stays QuestionOpen)
Reveal      --Next (mod) / auto--> BoardSelect (next picker) | GameOver (board empty)
  • Two roles, two fields. active_picker = whose turn to choose a cell; floored_player = who may answer now. How they relate depends on the answer policy (turn-order: floored == picker on pick; open-floor: floored set by first buzz).
  • Buzzing and answering are one phase (QuestionOpen), told apart by floored_player. The re-buzz-after-wrong loop never leaves it β€” it toggles floored_player and grows locked_out.
  • Reveal is a human-paced beat, not auto-timed by default: exits on mod Next, with an optional auto-advance timer.

Commands -> transitions

command grant effect
StartGame Moderate Lobby -> BoardSelect; shuffle pickers
PickCell{x,y} active_picker (or Moderate) BoardSelect -> QuestionOpen
Buzz Play claims floor (first wins); starts answer timer
Answer floored Play submit -> judge (Auto resolves; Moderator -> Pending)
Rule{verdict} Moderate resolves Pending / overrules -> may reach Reveal
Next Moderate Reveal -> BoardSelect / GameOver
EndGame Moderate -> GameOver
Grant / ExtendTimer / Kick Moderate controls, no phase change

Rule axes

Answer-side policy (buzz/steal/lockout/scoring/judge/timers) is read from the data-layer Rules; the picker-side policy (picker_mode) and reveal_auto_advance live in the data-layer GridQuizRules (on GridQuizGame). Both default from the manifest and the host may override either per session in the UI before StartGame. No separate runtime AnswerMode β€” the three buzz-in modes are UI presets over (buzz_policy, steal_policy).

Adjudication β€” the judge axis

"Was this answer correct / in time / valid" is its own pluggable axis, orthogonal to gamemode. A game is (Gamemode Γ— Judge), chosen independently.

Submission β‰  score; decisions are revisable

A submission enters a Pending state; a judgment resolves it to Correct / Incorrect / Void. Because a moderator can make a mistake and revise an earlier ruling, score is never an accumulated counter. Instead:

  • An append-only judgment log (global, spans all rounds on GameState): each entry is (game_idx, player, question, submission?, verdict, points, supersedes?).
  • points is the resolved award (cell value, half on steal, penalty…), recorded once at append. The fold just sums it β€” it can't re-derive steal/half math from a single entry, so the outcome is stored, not replayed (steal logic lives in one place: the append path).
  • score = fold(judgment_log) β€” recomputed from the log, never mutated in place.
  • Revising = append a new verdict that supersedes the old one, refold, rebroadcast. Because the client is render(view) (full snapshot), a refold just produces a new snapshot β€” no special "score correction" message.

submission is nullable: Some(typed_answer) when answers are typed, None when spoken (the moderator's verdict stands alone). This mirrors the data model's "correctness lives on data, derive don't duplicate."

v1 judges (two β€” behind a Judge trait)

Two real implementations justify building the trait now (unlike the gamemode trait, which has only one v1 case). The two are the genuinely distinct mechanisms:

  • Auto β€” static matcher against the data layer (correct: true, numeric answer + tolerance). Returns a resolved verdict synchronously. Covers MC / T-F / numeric. The default.
  • Moderator β€” submission lands Pending; a Moderate-granted human rules Correct / Incorrect / Void (and in-time or not) via a command. Covers open/freeform answers Auto can't match, and is the override path on top of Auto (a moderator revising an auto-verdict is the same append-to-log).

Trait shape these two reveal: Judge::verdict(submission, &question_data) -> Verdict where Verdict ∈ {Correct, Incorrect, Void, Pending}. Both feed the same append-only judgment log, so override/revision is identical regardless of which judge produced the original verdict β€” the symmetry that proves the seam is right.

Deferred judges (same trait, later):

  • ponytail: Quorum = the Moderator verdict path with vote aggregation; add when a sole-judge-conflict session (playing moderator) needs it.
  • ponytail: Llm = Auto with a model matcher + human fallback (NeedsHuman); add when freeform answers outgrow moderator throughput.

Which judge runs β€” judge = f(answer_input, question_kind)

The judge is resolved per question, not picked once per session and not a free per-question toggle. The only session knob is the answer-input axis:

  • spoken β†’ always Moderator (nothing to match; submission = None).
  • typed β†’ Auto if the question kind is auto-matchable (MC / numeric), else Moderator for open kinds.
  • hybrid β†’ a moderator may override any Auto verdict (the revision path).

Presence (co-located vs remote) is not a session mode. It falls out of grants

  • projection already: if a Present screen is connected, players' views can omit the question text; if remote, each player's view includes it. A view-population choice, not runtime state. So "online" is not a mode β€” only answer-input (spoken / typed / hybrid) is.

This means the same machine plays the full spectrum with different sections populated: a fully-vocal game is just buzzer + moderator (Buzz only, no typed answer, Moderator judge), a fully-typed game is buzzer + answer_input

  • Auto, and hybrids mix per question.

Timers

Two distinct clocks, both expressed as deadline timestamps in GameState (not countdowns), so any reconnecting or late-joining socket computes remaining time as deadline - now β€” a consistent clock for every view, and forward- compatible with the future SQLite path.

  • Question timer β€” how long the question stays open before expiring unanswered.
  • Answer timer β€” once a player has buzzed, how long they have to answer before forfeit.

The room task's select! races mpsc recv against sleep_until(deadline).

Moderator overrides

  • Extend β€” Command::ExtendTimer { deadline_delta } pushes the deadline out; rebroadcast snapshot updates every view's clock for free.
  • Overrule the ring β€” timer expiry is not final-and-instant. It resolves automatically as an auto-verdict on the judgment log (forfeit/expire), and a moderator overturns it via the same verdict-revision path as any other ruling. No separate RungOut pending state β€” expiry-overrule and verdict-revision are one mechanism. ponytail: timer expiry = auto-verdict; mod overrule reuses the revision path.

Decision summary

Decision Choice Deferred / future
Backend owner Rust + axum owns data + runtime β€”
Types ts-rs Rust β†’ TS, Rust is source of truth β€”
Transport REST (cold content) + WS (hot state) β€”
Concurrency actor-per-room: owning task + mpsc in + broadcast out β€”
Room registry DashMap<JoinCode, RoomHandle>, self-reaping β€”
Persistence in-memory only SQLite: room = opaque blob, history = tables (ADR 0004)
Room identity single 6-char join code, regen on collision opaque id only if needed
Auth gates creation secret (config), optional join password (UI) entry throttling
Reconnect opaque token in localStorage, slot persists β€”
State delivery full snapshot, tagged-enum envelopes deltas only if state outgrows a frame
Authorization capabilities (Play/Present/Moderate), compose by union β€”
Trust boundary single project(state, grants), server strips secrets β€”
Grant assignment joiner = {Play}; others via moderator-only Grant β€”
Gamemode (data) ModeState enum from start (GridQuiz + Linear) β€”
Gamemode (behavior) buzz/lockout/timer/scoring hardcoded in room task extract Gamemode trait when two modes have runtime behavior
Buzz/order open-floor first-buzz + lockout (gamemode default, mod override) turn-order, rotating announcer
Adjudication (Gamemode Γ— Judge), append-only judgment log, score = fold(log) β€”
v1 judges Auto + Moderator behind a Judge trait Quorum, Llm
Judge selection f(answer_input, question_kind); spoken/typed/hybrid β€”
Timers two deadline-timestamp clocks; mod extend / overrule via revision β€”