Skip to content

Latest commit

Β 

History

History
1015 lines (831 loc) Β· 40.2 KB

File metadata and controls

1015 lines (831 loc) Β· 40.2 KB

Quackster β€” Data Model

Status: partially implemented β€” schemas, data loader, cross-file validation, pool query engine, board builder, and an example dataset are working. Game runtime and UI wiring are not yet built. Scope: how quiz content (questions, packs, gamemodes, media, translations) is stored on disk, validated, and loaded at runtime.

Stack note: the data layer lives in Rust (api/src/data/, validation via garde, types exported to TS via ts-rs); the legacy TS/valibot implementation has been removed at parity. This doc remains canonical for content shape; the runtime is Rust + axum (REST + WebSocket), documented in docs/architecture.md (see also ADR docs/decisions/0001-rust-axum-backend-sveltekit-static.md). The validation checks and content rules are unchanged from the original TS implementation.

Goals

  1. Content lives in the repo as files β€” YAML, human-editable, PR-reviewable.
  2. Questions are reusable across gamemodes β€” no copy-pasting answers.
  3. Multiple gamemodes (classic, battle royale, survival, music quiz, …) each declare which question types they accept; loader filters automatically.
  4. Multi-media questions β€” text, image, audio, video on prompts and choices.
  5. i18n β€” questions can be translated, language-locked, or locale-relevant (cultural). Same workflow as ClassQuiz/GNOME (Weblate-compatible).
  6. Validated β€” schema-defined, with JSON Schema exported for editor support (YAML LSP) and CI checks. One schema definition drives parsing, validation, and editor support.
  7. Community-contributable β€” clear file layout, autocomplete in editors, PRs only touch the file they care about.
  8. Easily generated by LLM or Human - any LLM or human can generate questions or a whole quiz if they want to

High-level layout

data/
  questions/          # canonical content, language-neutral metadata + default lang
    geography/
      capitals.yaml
      flags.yaml
    science/
      chemistry.yaml
      math.yaml
      physics.yaml
  i18n/               # translation overlays, mirror of questions/ + packs/
    de/
      questions/
        geography/capitals.yaml
        geography/flags.yaml
        science/chemistry.yaml
        science/math.yaml
        science/physics.yaml
      packs/
        official/school-trivia.yaml
      tags/
        subject.yaml
        difficulty.yaml
        region.yaml
  packs/              # curated playlists (lists of question IDs)
    official/
      school-trivia.yaml
  tags/               # tag registry β€” one file per category
    subject.yaml
    difficulty.yaml
    audience.yaml
    region.yaml
    format.yaml
    warning.yaml
  media/              # binary assets referenced by questions
    img/
      flags/it.svg
      flags/ca.svg
      flags/kr.svg
      flags/ma.svg

gamemodes/            # code, not data; each declares compatibility metadata
  grid_quiz/          # Jeopardy-style β€” first implemented gamemode
    manifest.yaml
    boards/           # board definitions (category Γ— point grid)
      school.yaml

schemas/              # JSON Schema generated from the schema definitions, committed for editor support
  question.schema.json
  question-overlay.schema.json
  pack.schema.json
  pack-overlay.schema.json
  board.schema.json
  tag-registry-<category>.schema.json  # one per category
  tag-overlay-<category>.schema.json   # one per category

Layering principle

Three independent concerns, never mixed:

Layer Purpose Owns
Questions Raw facts content + correct answer + tags
Packs Curated playlists list of question IDs (or a filter query)
Gamemodes Rules / presentation scoring, timing, accepted question types

A question never knows which gamemode it'll be played in. A gamemode never hard-codes question content. Packs glue them at runtime.

Question schema

Kinds and variants

A question has a kind (the shape of the underlying fact) and zero or more variants (how that fact can be played). The same fact β€” "capital of France is Paris" β€” can show up as multiple-choice in one gamemode and free text in another, without duplicating the fact.

Kind Variants When
text multiple_choice, true_false, open Fact with a textual answer
numeric multiple_choice, numeric_input, range Fact with a numeric answer
order (none β€” inherently multi-item) Arrange items chronologically/etc.

Variants are optional. A question with only open defined is invisible to a gamemode that requires multiple_choice. Authors add variants as needed; the play variant is declared at the game definition and resolved per question-slot at materialize time (see Variant resolution).

Canonical (text kind, English example)

# data/questions/geography/capitals.yaml
# yaml-language-server: $schema=../../../schemas/question.schema.json

- id: q_capital_france_paris # stable, globally unique (see ID strategy)
  kind: text
  tags:
    - subject:geography
    - subject:capitals
    - subject:europe
    - difficulty:general
    - region:global
  sources:
    - { url: 'https://example.org/', accessed: '2025-06-01' }
  license: CC-BY-4.0 # SPDX id from allowlist; optional, per-question override
  # lang_locked: en               # optional β€” question only valid in this lang
  # deprecated:                   # optional β€” see "Deprecation"
  #   reason: "Boundary changed after 2024 referendum"
  #   replaced_by: q_capital_france_paris_v2
  content:
    default_lang: en # language of the canonical strings below
    prompt:
      text: 'What is the capital of France?'
      # media: …                  # optional, see "Media" section
    answer: Paris # canonical short form; drives all variants
    explanation: 'Paris has been the capital since 987 AD.'
    variants:
      open:
        accepted: ['Paris', 'Paris, France']
        normalize: [lowercase, strip_diacritics, strip_punctuation]
      multiple_choice:
        choices:
          - { id: paris, text: Paris, correct: true }
          - { id: london, text: London }
          - { id: berlin, text: Berlin }
          - { id: madrid, text: Madrid }
      # true_false omitted β†’ question not playable in T/F gamemodes

Canonical (numeric kind)

- id: q_moon_landing_year
  kind: numeric
  tags: [subject:history, subject:space, difficulty:general]
  content:
    default_lang: en
    prompt: { text: 'Year of the first crewed Moon landing?' }
    answer: 1969
    unit: year # optional; loader does not convert, just labels
    variants:
      numeric_input: { tolerance: 0 }
      range: { min: 1960, max: 1979, step: 1 }
      multiple_choice:
        choices:
          - { id: y1969, text: '1969', correct: true }
          - { id: y1965, text: '1965' }
          - { id: y1972, text: '1972' }
          - { id: y1959, text: '1959' }

Canonical (order kind)

- id: q_ww1_events_order
  kind: order
  tags: [subject:history, difficulty:niche]
  content:
    default_lang: en
    prompt: { text: 'Arrange these WWI events chronologically.' }
    items:
      - { id: assassination, text: 'Assassination of Franz Ferdinand', position: 1 }
      - { id: lusitania, text: 'Sinking of the Lusitania', position: 2 }
      - { id: us_enters, text: 'US enters the war', position: 3 }
      - { id: armistice, text: 'Armistice signed', position: 4 }

Hard rule: everything inside content is translatable. Everything outside is metadata that does not change per language. This makes Weblate config trivial and makes it impossible for a translator to accidentally edit a correctness flag, position, or numeric answer. default_lang lives inside content because it describes the canonical strings; tags, license, and deprecation do not depend on it.

Correctness lives on the data, not in a separate answer key. Choices use correct: true; order items use position: N; numeric questions use answer: <number>. There is no answer: field referencing IDs to keep in sync. Schema marks correct and position as non-translatable so overlays cannot change them.

Difficulty is subjective and context-dependent. It's expressed via tags in the difficulty: category β€” see Tags. Gamemodes that need a hard ordering (e.g. Quiz Duell tile values, Survival ramp-up) get it from the pack's curated placement, not from the question.

Translation overlay

# data/i18n/de/questions/geography/capitals.yaml
# yaml-language-server: $schema=../../../../../schemas/question-overlay.schema.json

- id: q_capital_france_paris
  content:
    prompt:
      text: 'Was ist die Hauptstadt von Frankreich?'
    answer: Paris
    explanation: 'Paris ist seit 987 die Hauptstadt.'
    variants:
      open:
        accepted: ['Paris']
      multiple_choice:
        choices:
          - { id: paris, text: Paris }
          - { id: london, text: London }
          - { id: berlin, text: Berlin }
          - { id: madrid, text: Madrid }

Overlay uses the same shape as canonical. Choice / item / variant keys match canonical β†’ correctness, positions, and numeric answers are never restated and cannot drift. Schema rejects overlays that include non-translatable fields (correct, position, answer on numeric, tolerance, min/max/step).

Deprecation

Questions are part of the public API of the repo and must not be deleted once referenced by packs. To retire a question:

- id: q_old_capital_burma
  deprecated:
    reason: 'Country renamed to Myanmar; question wording is outdated.'
    replaced_by: q_capital_myanmar # optional
  # … rest of question stays for backward compat

Deprecated questions are excluded from pool builds. CI warns on packs that still list them. The reason is a free-form note for contributors (not translated) β€” important because nuance matters: "answer changed after 2024 referendum" is very different from "duplicate of q_xyz".

Tags

Tags do two jobs:

  1. Stable identifier for filtering (tags: [subject:chemistry] on a question).
  2. Translatable display label shown in UI ("Chemie" for a German player).

Mixing them is pain. So: tags in question files are stable category:slug identifiers; their human labels live in a separate registry, translated like any other content.

Format

Every tag is category:slug. Category is explicit at the use site β€” no guessing whether europe means the continent (subject:europe) or the cultural region (region:europe). The category part is a closed enum (see Categories); adding a new category is a schema PR. The slug part is registry-validated.

Category is derived in the loader via id.split(":")[0]; registry entries are keyed by the full category:slug id and don't repeat the category as a separate field.

Registry

The registry is split by category, one file per category under data/tags/. This keeps each file small enough for translation tools to handle comfortably and lets PRs touch a single file when adding tags.

# data/tags/subject.yaml
# yaml-language-server: $schema=../../schemas/tag-registry-subject.schema.json

- id: subject:chemistry
  default_lang: en
  label: Chemistry
  description: Chemical elements, reactions, compounds

- id: subject:geography
  default_lang: en
  label: Geography
# data/tags/difficulty.yaml
- id: difficulty:general
  default_lang: en
  label: General knowledge
  description: Most casual players are expected to know this

- id: difficulty:niche
  default_lang: en
  label: Niche
  description: Specialist or fan knowledge

- id: difficulty:trick
  default_lang: en
  label: Trick question
  description: Sounds harder/easier than it is; wordplay or misdirection
# data/tags/region.yaml
- id: region:dach
  default_lang: en
  label: DACH (DE/AT/CH)
  description: Culturally most relevant in German-speaking countries

- id: region:global
  default_lang: en
  label: Global
# data/tags/warning.yaml
- id: warning:nsfw
  default_lang: en
  label: NSFW
  description: Adult content; hosts may want to filter at room creation.

- id: warning:violence
  default_lang: en
  label: Violence

- id: warning:dark_humor
  default_lang: en
  label: Dark humor

The schema enforces that an entry's id prefix matches the file it lives in (subject:* only in subject.yaml etc.) β€” typos can't smuggle a tag into the wrong file.

Overlay

Overlays mirror the split:

# data/i18n/de/tags/subject.yaml
- id: subject:chemistry
  label: Chemie
  description: Chemische Elemente, Reaktionen, Verbindungen
# data/i18n/de/tags/difficulty.yaml
- id: difficulty:general
  label: Allgemeinwissen
- id: difficulty:niche
  label: Nischenwissen
- id: difficulty:trick
  label: Fangfrage

Same Weblate workflow as questions. Translators see label + description, never id.

Categories (axes)

Categories are a closed enum enforced by the schema. Adding a new category is a schema PR; adding a tag inside an existing category is a registry PR.

Category Examples Purpose
subject subject:chemistry, subject:geography, subject:pop_music What it's about. Filter / theme packs.
difficulty difficulty:easy, difficulty:general, difficulty:niche, difficulty:expert, difficulty:trick Qualitative hint, replaces 1–5 scale.
audience audience:requires_stem, audience:kids_friendly, audience:adults Who's expected to know it.
region region:dach, region:uk, region:us, region:global Cultural relevance (soft hint, not hard locale lock).
format format:wordplay, format:visual, format:audio Mechanical hint for gamemode compatibility.
warning warning:nsfw, warning:violence, warning:dark_humor Content warnings; hosts can filter at room creation.

The explicit category:slug syntax means namespace collisions are free: subject:europe (the continent) and region:europe (cultural relevance) can coexist without slug-mangling.

CI rules

  • Every tag matches ^[a-z][a-z_]*:[a-z][a-z0-9_]*$ (category + slug, both lowercase + underscores).
  • The category half must be in the closed enum (subject, difficulty, audience, region, format). Unknown category β†’ schema error.
  • The full category:slug must exist in the registry β†’ typo prevention on the slug half.
  • Tag ids are part of the public API (same rule as question IDs): no rename without a deprecation marker.
  • Adding a tag = one PR touching registry.yaml + relevant overlay files.
  • Adding a category = schema PR (rare, by design).

Variant sub-schemas

Each variant has a fixed shape. The schema is a discriminated union over kind, with per-kind discriminated unions over variant name.

Variant Required fields
multiple_choice choices[] with id, text, optional correct: true. At least one correct.
true_false correct (bool).
open accepted[] (strings), optional normalize[].
numeric_input tolerance (number, default 0).
range min, max, optional step (default 1), optional tolerance (default 0).

order is a kind, not a variant β€” its shape is content.items[] with id, text, position. No variants.

normalize operators (open variant): lowercase, strip_diacritics, strip_punctuation, strip_whitespace, strip_articles. Applied in array order to both the player's input and each accepted entry before comparison.

Media

Schema

Media is an array on prompt and optionally on individual multiple_choice variant choices (e.g. image-answer questions) or order items.

content:
  prompt:
    text: 'Which band released this song?'
    media:
      - kind: audio # image | audio | video
        ref: 'local:audio/wonderwall-clip.ogg'
        alt: '8-second clip of a guitar riff' # accessibility
  variants:
    multiple_choice:
      choices:
        - id: oasis
          text: Oasis
          correct: true
          media: # choices can also have media
            - { kind: image, ref: 'local:img/oasis-logo.svg', alt: Oasis logo }

Overlays may localize media at any level (prompt, choice, order item) by restating the media: array β€” see Per-locale media. Media arrays are replaced wholesale by overlays, not merged.

Storage strategy

Recommended: hybrid.

Asset type Storage Reason
Small images (< 100 KB, SVG/WebP) In-repo at data/media/img/ Fast, no bandwidth cost
Audio / video clips External refs or release-tarball assets Git is bad at binaries
User-uploaded media (future) Object storage (S3/MinIO) configured by self-host Not part of OSS dataset

Ref format

ref uses the same prefix:value syntax as tags. Three sources:

Prefix Value Resolves to
local: path relative to data/media/ Local file shipped with the repo.
url: absolute https:// URL Arbitrary remote asset.
youtube: video ID, optional clip bounds yt-dlp segment (youtube:abc123?start=10).

Examples:

ref: 'local:img/flags/it.svg'
ref: 'url:https://example.org/clip.mp3'
ref: 'youtube:pkndFYSTr0Y?start=10&end=18'

Clip bounds are youtube-only. ?start= / ?end= take decimal seconds (start=95.5), normalized to ms internally; either bound may be omitted (?end=12 = first 12 s). The server downloads only that segment via yt-dlp, so clients always receive pre-trimmed files β€” no client-side clipping exists. local: and url: media must be trimmed before adding (a future editor UI will trim uploads at creation time).

Each prefix is validated by its own schema (URL parsing, YouTube ID regex, local path disallowing ..). Adding a new source (e.g. s3:) is a schema PR. Self-hosters can pin or mirror media as they like. (youtube:/url: refs need internet; only local: is guaranteed offline β€” see the offline-capable note in docs/architecture.md.)

CI rules

  • For local: refs: file must exist under data/media/, file extension must match declared kind (e.g. kind: audio rejects .png).
  • For local: refs: file size cap (100 KB for images, 1 MB for audio/video β€” enforced at load/validate; large clips should be url: or youtube:).
  • For url: refs: must be https://, scheme-validated only β€” no liveness check in CI (too flaky).
  • For youtube: refs: video ID must match ^[A-Za-z0-9_-]{8,24}$ (generous range, Google may extend beyond 11 chars). Query params: only start and end (non-negative decimal seconds, start < end, no duplicates) β€” anything else (typos, pasted si= tracking junk) is a hard error. kind: image with a youtube: ref is invalid β€” youtube is inherently temporal.

Per-locale media

Sometimes media itself needs translation (narrated audio, image with text). Overlay can replace the media array:

# data/i18n/de/questions/music/podcasts.yaml
- id: q_podcast_intro_clip
  content:
    prompt:
      media:
        - { kind: audio, ref: 'local:audio/de/q-podcast-intro-clip.ogg' }

If no localized media is provided, the canonical media is served.

Boards

A board is a 2D grid (categories Γ— point values) used by grid-based gamemodes like Jeopardy/Grid Quiz. Boards live under gamemodes/<id>/boards/ and reference packs, filters, or explicit question IDs per category.

# gamemodes/grid_quiz/boards/school.yaml
# yaml-language-server: $schema=../../../schemas/board.schema.json

id: board_school
title: School Quiz
description: A 4Γ—4 board covering capitals, flags, chemistry, math, and physics.
points: [100, 200, 300, 500]
difficulty_map:
  100: [difficulty:easy]
  200: [difficulty:general]
  300: [difficulty:niche]
  500: [difficulty:general, difficulty:niche]
categories:
  - name: Capitals
    filter:
      tags_any: [subject:capitals]
  - name: Flags
    filter:
      tags_any: [subject:flags]
  - name: Chemistry
    question_ids:
      100: { id: q_atom_nucleus, variant: multiple_choice }
      200: { id: q_acid_base, variant: open }
      300: { id: q_chemical_oxygen_ozone, variant: multiple_choice }
      500: { id: q_periodic_helium, variant: true_false }
  - name: Math & Physics
    pack_ref: pack_school_trivia
    filter:
      tags_any: [subject:math, subject:physics]

Resolution per (category, point) slot (first match wins):

  1. question_ids[point] β€” explicit override, always wins.
  2. pack_ref β€” resolved pack's questions, AND-ed with filter and difficulty_map.
  3. filter β€” dynamic pool query, AND-ed with difficulty_map.

difficulty_map maps each point value to a set of tags β€” the board builder filters candidates to questions that carry at least one of those tags. This lets a Jeopardy-style board ramp difficulty without each category repeating tag filters.

The board builder deduplicates across the whole board (no question appears twice) and shuffles with a deterministic seed (mulberry32 PRNG) so boards are reproducible given the same seed.

Packs

A pack composes questions from three sources, in this evaluation order:

  1. includes: β€” other packs to splice in.
  2. questions: β€” explicit curated list of question IDs.
  3. filter: β€” dynamic query over the global pool.

All three may be combined; results are unioned, then deduplicated by question ID. A pack with none of them is a schema error.

# data/packs/official/britpop-trivia.yaml
# yaml-language-server: $schema=../../../schemas/pack.schema.json

id: pack_britpop
title: Britpop Trivia
description: 90s UK music quiz
author: lucas
license: CC-BY-4.0 # SPDX id from the allowlist
recommended_gamemodes: [music_quiz, classic, survival]

includes:
  - pack_90s_general # splice in another pack's resolved questions

questions:
  - q_oasis_wonderwall_year
  - q_blur_parklife_album
  - q_pulp_common_people_singer

filter:
  tags_all: [subject:music, region:uk]
  tags_any: [difficulty:general, difficulty:niche]
  tags_none: [warning:nsfw]
  kinds: [text]
  variants_any: [multiple_choice, open]
  limit: 20

Filter semantics (exact operator precedence and shuffle/limit/seed behavior) are defined in the loader section once that exists. For now: all listed filters are ANDed; tags_all/tags_any/tags_none work on the question's tag set; limit caps result count.

Pack translations are minimal β€” title/description only, questions are shared:

# data/i18n/de/packs/official/britpop-trivia.yaml
id: pack_britpop
title: Britpop-Quiz
description: 90er-Jahre UK-Musikquiz

License compatibility

Both questions and packs use SPDX identifiers from a small allowlist (CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0, … β€” maintained in the schema). CI rejects unknown identifiers and warns when a pack's license is incompatible with any of its included questions' licenses (e.g. CC-BY pack including a CC-BY-SA question is a contamination risk).

Gamemodes

Gamemodes are code, not data, but each ships a small declarative manifest describing what content it accepts and how it presents the game.

# gamemodes/battle_royale/manifest.yaml
id: battle_royale
name: Battle Royale
description: Last player standing. Wrong answer = elimination.
accepts:
  kinds: [text, numeric]
  variants: [multiple_choice, true_false]
  max_choices: 4
  min_choices: 2
requires:
  timer: true
  min_players: 2
ui:
  player_view: PlayerView.svelte
  host_view: HostView.svelte
  spectator_view: SpectatorView.svelte

Gamemode IDs are bare slugs β€” the directory name under gamemodes/ is authoritative.

The gamemode's runtime rules, scoring, and state machine live in the Rust backend. For v1 grid_quiz is hardcoded in the room task; a Gamemode trait is extracted when the second gamemode lands. Live state reaches clients over WebSocket. See docs/architecture.md (Gamemode model).

Gamemode compatibility

A question is playable in a gamemode iff it passes the accepts gate:

  • kind must be in accepts.kinds.
  • At least one variant defined on the question must be in accepts.variants.
  • For multiple_choice: choices.length within min_choices..max_choices.
  • lang_locked must be empty or equal to the player's locale.

Questions failing the gate are dropped at pool-build. The gate filters which questions are playable β€” it does not choose which variant is played. That is variant resolution.

Variant resolution

A question may declare a preferred_variant β€” the variant it should play as by default. A board cell may override that on a per-question basis. If neither applies (or the chosen variant isn't defined on the question), the kind default is used. The choice is made at materialize time (board build, linear resolve, room state init) and stored alongside the question id as a QuestionSlot (board grid, linear question list, live CurrentCell).

Precedence (top wins):

  1. Board cell override β€” a BoardCell with an explicit variant (and that variant defined on the question) wins over every other source.
  2. Question declared β€” preferred_variant on the question, if defined.
  3. Kind default β€” the kind's default variant (open for text, not applicable for numeric yet, none for order which has no variant dimension).
  4. Fallback β€” if the kind default isn't defined on the question (e.g. a numeric question with no open variant), pick the first available variant in deterministic order (multiple_choice, true_false, numeric_input, range).

Order questions carry variant = None β€” they have no variant dimension.

Planned (not yet implemented): pack-level variant override on pack_ref/filter sources, then uniform random fallback as the final layer. Precedence will be pack-override > board-cell > question-declared

random.

# question β€” author declares the default play variant
id: q_atom_nucleus
kind: text
preferred_variant: open # optional; falls back to kind default if absent
tags: [subject:chemistry]
content:
  default_lang: en
  prompt: { text: 'What is the charge of an electron?' }
  answer: 'negative'
  variants:
    multiple_choice:
      choices:
        - { id: neg, text: 'negative', correct: true }
        - { id: pos, text: 'positive' }
    open:
      accepted: ['negative', '-']
# board cell β€” explicit question + optional variant override per cell
categories:
  - name: Chemistry
    question_ids:
      100: { id: q_atom_nucleus, variant: open } # board override
      200: { id: q_acid_base, variant: numeric_input } # board override
      300: { id: q_chemical_oxygen_ozone } # no override β†’ default
      500: { id: q_periodic_helium } # no override β†’ default

A board cell's override must reference a variant the question defines; cross-file validation flags a dead override as a load issue.

Resolution rule lives in api/src/data/types/question.rs (Question::resolve_variant) and is invoked by build_board / resolve_linear at materialize time. The chosen variant flows into the QuestionSlot carried by the live CurrentCell, which is what projection and judgment read.

Gamemodes that care about difficulty consume it via tags (e.g. Survival could request tags_any: [difficulty:easy, difficulty:general] for the first round, difficulty:niche later). Quiz Duell ignores it β€” tile value comes from the pack's board layout.

Pack authors don't need to know which gamemodes exist. Adding a new gamemode "just works" against existing question pool, modulo compatibility filtering.

i18n

Classification

Questions fall into three categories β€” schema must express all three:

Category Field / mechanism Example
Universal (no marker) "Capital of France?" β€” translate freely
Language-locked lang_locked: en "Rhymes with 'cat'?" β€” pinned to one lang, loader hides elsewhere
Locale-relevant region:* tag "Bundesliga 2020 winner?" β€” region:dach, soft hint not a filter

Resolution order

For a question rendered to a player with locale userLocale:

  1. If lang_locked is set and β‰  userLocale β†’ question is hidden.
  2. Otherwise, for each translatable field:
    1. Use the overlay in data/i18n/<userLocale>/… if present.
    2. Else use the overlay in data/i18n/en/… if present.
    3. Else use the canonical string (in content.default_lang).

The UI must surface when a player sees a non-localized question so they can request or contribute a translation (e.g. a "machine-translate this" button backed by an external translation service). This is a runtime concern β€” no file-level flag.

Repo policy: canonical content should be authored in English whenever possible (so the English-overlay fallback always works), but content.default_lang is per-question because some content is genuinely born in another language (a German-only pack contributed by a German author shouldn't be blocked waiting for translation).

The exact loader implementation (merge semantics for objects, arrays, choice-id-keyed lists, media replacement) is defined in the loader section once that exists.

Tooling

The repo layout is designed to plug Weblate or Crowdin directly:

  • Translation files live under data/i18n/<lang>/ mirroring canonical paths.
  • Translators only see translatable fields (the schema enforces this).
  • New languages = new directory; no canonical files modified.
  • Partial translations are fine; loader silently falls back.

IDs

Globally unique, stable, never reused.

Format: type prefix + descriptive slug. The slug is a hint for humans reading diffs/logs; it is not authoritative and carries no semantic meaning the loader can rely on.

q_<descriptive_slug>                 e.g. q_capital_france
pack_<slug>                          e.g. pack_school_trivia
board_<slug>                         e.g. board_school
<slug>                               e.g. grid_quiz  (gamemodes, bare slug)

Rules:

  • Lowercase, underscores only.
  • Slug describes the question's content, not its category. Moving a question from geography/ to history/ does not require renaming it β€” the ID is a historical label, not a path.
  • Concurrent-PR collisions are resolved by uniqueness of the descriptive slug itself; CI enforces global uniqueness, the later PR renames.
  • IDs are part of the public API of the repo β€” no removals or renames without a deprecation marker (see Deprecation).

Tooling

A pnpm new-question script scaffolds a new entry: picks a unique slug given a descriptive title, writes a stub with required fields and the right tag categories, and prints the new ID. Lowers friction for contributors and reduces concurrent-PR slug collisions.

File grouping

One file per topic-subtopic, target 20–50 questions, soft cap ~100.

Why:

  • Translation tools (Weblate) work per-file. Few large files > thousands of tiny.
  • PR review: 1 file changed when adding 5 related questions, not 5 files.
  • Filesystem: 10k+ tiny files break IDE indexing.
  • Sibling questions in the same file help catch duplicates and tone drift.

Question IDs are flat-namespaced, so files can be reshuffled without breaking pack references.

Validation

Schemas

The schema definitions are the single source of truth β€” Rust types validated by garde (api/src/data/types/). They drive:

  1. Native types for the data layer.
  2. TS types for the frontend β€” via ts-rs.
  3. JSON Schema for editor YAML LSP β€” not yet re-sourced from Rust (the old TS-generated schemas/*.json were removed with the legacy layer).

Validation points

Where What Status
Editor (developer/contributor) YAML LSP via JSON Schema β€” pending Rust-sourced export β—‹
CI Loads every YAML, runs schema + cross-file checks, fails on errors βœ…
Runtime (server start / pack load) Same schemas, fail loudly with question ID context βœ…

What CI must catch

Implemented checks are marked βœ…; planned but not yet built are β—‹.

  • βœ… Schema violations (validated on every YAML file).
  • βœ… Duplicate IDs (across all questions/packs).
  • βœ… Dangling pack references: questions, includes, replaced_by pointing to nonexistent IDs.
  • β—‹ Packs that still reference deprecated questions (warning, not error).
  • βœ… Pack includes cycles (DFS cycle detection).
  • βœ… Missing media files referenced by local: refs; url:/youtube: are format-checked only.
  • βœ… media.kind disagrees with file extension for local refs (video-as-audio allowed).
  • βœ… Local media exceeds size cap (100 KB images, 1 MB audio/video).
  • βœ… multiple_choice variant with zero correct: true choices (checked on the choices array).
  • βœ… order items with duplicate or non-contiguous position values (checked on the items array).
  • βœ… Translation overlays referencing nonexistent question IDs.
  • βœ… Tag refs on questions/packs that don't exist in the registry.
  • βœ… License identifiers outside the SPDX allowlist (closed enum).
  • β—‹ Pack license incompatible with included questions' licenses (warning).
  • β—‹ Translation overlays setting non-translatable fields (correct, position, numeric answer, variant config) β€” currently blocked by strict-object schema but not explicitly checked after merge.

Runtime integration

Questions and packs are static-ish β€” the Rust backend loads them at startup, validates, and holds them in memory (api/src/data/, see main.rs). A self-hosted instance reloads on restart when new content is dropped in.

The data layer provides:

  • load β€” walks data/, parses every YAML, validates, builds registry maps keyed by ID (loader.rs).
  • cross-file checks β€” dangling refs, tag refs, overlay refs, pack cycles, media files (validate.rs).
  • pool query β€” filters the question pool by pack filter semantics (query.rs).
  • board builder β€” resolves a board definition into a 2D grid of question IDs (board.rs).

The game runtime (rooms, WebSocket state streaming, scoring) is documented in docs/architecture.md. The data layer has zero coupling to the runtime; gamemodes consume it plus the live-session machinery. Live state reaches clients over WebSocket (REST serves the cold content above).

Decisions

These were debated; the design above reflects the chosen path.

  • Per-question default_lang with soft "prefer English" repo policy β€” not forced English-canonical.
  • Silent translation fallback β€” missing overlays serve the canonical string; the UI surfaces that a question is non-localized.
  • Kinds + variants instead of one type per question β€” one fact can be played as MC, T/F, open, etc. without duplication.
  • correct: true on choices instead of a separate answer key β€” impossible to desync; overlays cannot edit correctness.
  • Pack composition via includes instead of forcing authors to copy question IDs across packs.
  • Tag registry split by category file, not one giant registry.
  • Media refs use tag-style prefix:value (local:, url:, youtube:) for consistency with tags. The local prefix is local: (was media: in early design β€” renamed to avoid confusion with the data/media/ directory).
  • License is SPDX from a small allowlist, validated by schema.
  • No file-level translation completeness output fields β€” the loader computes that at runtime; nothing leaks back into YAML.

Open questions

  1. Community packs β€” same repo (PR-reviewed) or separate submission pipeline? Start with same repo under data/packs/community/.
  2. Versioning β€” should questions carry a revision field for "this answer was updated"? Probably yes once we hit the first real correction.
  3. RTL languages (Arabic, Hebrew) β€” schema is fine, UI must support from day one to avoid retrofit pain.
  4. Editor UI (web-based quiz builder) β€” out of scope for first pass, but the schema must be friendly to it (the JSON Schema export can drive form validation).
  5. Duplicate-fact detection β€” two differently-worded questions for the same fact can co-occur in a pack. Optional future fact_id field for loader-level dedup; defer until it actually bites.
  6. Overlay merge semantics β€” the loader currently replaces overlay fields wholesale (no deep merge of variant sub-objects). Is this sufficient, or do we need partial merge (e.g. override one choice text without restating the whole choices array)?
  7. pnpm new-question script β€” scaffolds a new question entry with a unique slug, required fields, and correct tag categories. Not yet built.
  8. Gamemode manifest validation β€” gamemode manifests are not parsed/validated by the Rust data layer yet (only recommended_gamemodes IDs on packs are checked). Add a Rust manifest type + validation if/when gamemode loading is wired. (Gamemodes are code + manifest, less editor-driven than questions/packs.)

Implementation order

Steps 1–6 are done in Rust (api/src/data/); the data layer was first built in TS and then ported to Rust at parity, after which the legacy TS layer (src/lib/server/data/, src/lib/schemas/) was removed β€” same content rules, same checks.

  1. βœ… Schema definitions / types (question, question-overlay, pack, pack-overlay, board, tag, media, common) β€” Rust, validated by garde.
  2. βœ… Data loader (parse YAML β†’ validate β†’ build indexes). Cross-file validation: duplicate IDs, dangling refs, tag refs, overlay refs, pack cycles, media existence/kind/size.
  3. βœ… Example dataset: 5 canonical question files (~20 Qs across text/numeric/ order kinds, with local: media refs), German overlays, 1 pack, 6 tag registries (3 populated), 4 flag SVGs.
  4. βœ… Pool query engine β€” query_pool(filter) with ANDed kinds, tags_all, tags_any, tags_none, variants_any, limit.
  5. βœ… Board builder β€” resolves board categories via explicit IDs, pack refs, or filter queries; deduplicates; deterministic shuffle via mulberry32 PRNG.
  6. βœ… First gamemode: grid_quiz (Jeopardy-style) with manifest + board YAML. No runtime wiring yet.
  7. β—‹ Game runtime (rooms, WebSocket, scoring) β€” see docs/architecture.md.
  8. β—‹ Second gamemode (battle_royale or music_quiz) to validate the gamemode-agnostic claim.
  9. β—‹ new-question scaffolding script.
  10. β—‹ Rust JSON Schema export for editor YAML LSP.
  11. β—‹ Deprecated-question warnings in validation.