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 indata-model.md; UX flow is ingame-flow.md. This doc is the canonical reference for backend ownership, transport, concurrency, and the game-session model.
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../buildin dev). No SvelteKit server, no SSR, no remote functions. ts-rsexports Rust types to TypeScript. Rust is the single source of truth for all shared types (content + wire protocol); the frontend imports generated.ts.
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.
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.
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
wsfeature).
ts-rs exports both the REST DTOs and the WS message types, so the Svelte
client is fully typed against the Rust definitions.
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'smpsc::Sender. A player action (Buzz,Answer, β¦) is sent as aCommandinto the channel. The room task is the single receiver, pulling commands one at a time β input is serialized, so there are no data races onGameStateand first-buzz-wins falls out of channel ordering (no explicit race handling).- One
broadcast(oneβmany): room β players. The room task holds abroadcast::Sender<GameState>; every socket holds aReceiver. On each state change the room broadcasts the full-truthGameStateonce; all sockets receive a copy. - Room task loop is a
tokio::select!racing: nextCommandfrom 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
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.
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.
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.
- 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.
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.
SetLocalechanges 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.
- Client β server: a
Commandenum (Join,Buzz,Answer,Next,StartGame,Grant,ExtendTimer, β¦). - Server β client: a
ServerMsgenum (Snapshot(ClientView),Joined,Error, β¦).
serde #[serde(tag = "kind")] β clean discriminated unions in TS via ts-rs.
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.
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}.
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.
projectis the single gate. - This is the one place security is not simplified away:
projectcarries a test asserting a{Play}-only view never containscorrect_answer. - Composition is free: a playing-moderator's
ClientViewsimply has buzzer + controls + correct_answer populated. One channel, oneproject, 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.
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.
- The creator of a room gets
Moderate(+Present, optionallyPlay), bound to their reconnect token. Creating requires the server creation secret, soModerateis 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, revokingPlay) are assigned after join by an existing moderator via aCommand::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
Commandbut 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.
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.
A policy axis owned by the gamemode (its default) and overridable by the moderator per game:
- open-floor first-buzz β question goes live, any
Playgrant 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.
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.
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 }
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 byfloored_player. The re-buzz-after-wrong loop never leaves it β it togglesfloored_playerand growslocked_out. - Reveal is a human-paced beat, not auto-timed by default: exits on mod
Next, with an optional auto-advance timer.
| 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 |
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).
"Was this answer correct / in time / valid" is its own pluggable axis,
orthogonal to gamemode. A game is (Gamemode Γ Judge), chosen independently.
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?). pointsis 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."
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, numericanswer+tolerance). Returns a resolved verdict synchronously. Covers MC / T-F / numeric. The default.Moderatorβ submission landsPending; aModerate-granted human rulesCorrect/Incorrect/Void(and in-time or not) via a command. Covers open/freeform answersAutocan't match, and is the override path on top ofAuto(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 = theModeratorverdict path with vote aggregation; add when a sole-judge-conflict session (playing moderator) needs it.ponytail:Llm =Autowith a model matcher + human fallback (NeedsHuman); add when freeform answers outgrow moderator throughput.
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 β
Autoif the question kind is auto-matchable (MC / numeric), elseModeratorfor open kinds. - hybrid β a moderator may override any
Autoverdict (the revision path).
Presence (co-located vs remote) is not a session mode. It falls out of grants
- projection already: if a
Presentscreen 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.
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).
- 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
RungOutpending state β expiry-overrule and verdict-revision are one mechanism.ponytail:timer expiry = auto-verdict; mod overrule reuses the revision path.
| 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 | β |