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 viagarde, types exported to TS viats-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 indocs/architecture.md(see also ADRdocs/decisions/0001-rust-axum-backend-sveltekit-static.md). The validation checks and content rules are unchanged from the original TS implementation.
- Content lives in the repo as files β YAML, human-editable, PR-reviewable.
- Questions are reusable across gamemodes β no copy-pasting answers.
- Multiple gamemodes (classic, battle royale, survival, music quiz, β¦) each declare which question types they accept; loader filters automatically.
- Multi-media questions β text, image, audio, video on prompts and choices.
- i18n β questions can be translated, language-locked, or locale-relevant (cultural). Same workflow as ClassQuiz/GNOME (Weblate-compatible).
- Validated β schema-defined, with JSON Schema exported for editor support (YAML LSP) and CI checks. One schema definition drives parsing, validation, and editor support.
- Community-contributable β clear file layout, autocomplete in editors, PRs only touch the file they care about.
- Easily generated by LLM or Human - any LLM or human can generate questions or a whole quiz if they want to
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
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.
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).
# 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- 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' }- 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.
# 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).
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 compatDeprecated 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 do two jobs:
- Stable identifier for filtering (
tags: [subject:chemistry]on a question). - 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.
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.
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 humorThe 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.
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: FangfrageSame Weblate workflow as questions. Translators see label + description,
never id.
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.
- 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:slugmust 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).
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 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.
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 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.)
- For
local:refs: file must exist underdata/media/, file extension must match declaredkind(e.g.kind: audiorejects.png). - For
local:refs: file size cap (100 KB for images, 1 MB for audio/video β enforced at load/validate; large clips should beurl:oryoutube:). - For
url:refs: must behttps://, 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: onlystartandend(non-negative decimal seconds,start < end, no duplicates) β anything else (typos, pastedsi=tracking junk) is a hard error.kind: imagewith ayoutube:ref is invalid β youtube is inherently temporal.
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.
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):
question_ids[point]β explicit override, always wins.pack_refβ resolved pack's questions, AND-ed withfilteranddifficulty_map.filterβ dynamic pool query, AND-ed withdifficulty_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.
A pack composes questions from three sources, in this evaluation order:
includes:β other packs to splice in.questions:β explicit curated list of question IDs.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: 20Filter 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-MusikquizBoth 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 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.svelteGamemode 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).
A question is playable in a gamemode iff it passes the accepts gate:
kindmust be inaccepts.kinds.- At least one variant defined on the question must be in
accepts.variants. - For
multiple_choice:choices.lengthwithinmin_choices..max_choices. lang_lockedmust 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.
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):
- Board cell override β a
BoardCellwith an explicitvariant(and that variant defined on the question) wins over every other source. - Question declared β
preferred_varianton the question, if defined. - Kind default β the kind's default variant (
openfor text, not applicable for numeric yet,nonefor order which has no variant dimension). - Fallback β if the kind default isn't defined on the question (e.g.
a numeric question with no
openvariant), 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 β defaultA 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.
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 |
For a question rendered to a player with locale userLocale:
- If
lang_lockedis set and βuserLocaleβ question is hidden. - Otherwise, for each translatable field:
- Use the overlay in
data/i18n/<userLocale>/β¦if present. - Else use the overlay in
data/i18n/en/β¦if present. - Else use the canonical string (in
content.default_lang).
- Use the overlay in
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.
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.
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/tohistory/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).
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.
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.
The schema definitions are the single source of truth β Rust types validated by
garde (api/src/data/types/). They drive:
- Native types for the data layer.
- TS types for the frontend β via
ts-rs. - JSON Schema for editor YAML LSP β not yet re-sourced from Rust (the old
TS-generated
schemas/*.jsonwere removed with the legacy layer).
| 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 | β |
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_bypointing to nonexistent IDs. - β Packs that still reference deprecated questions (warning, not error).
- β
Pack
includescycles (DFS cycle detection). - β
Missing media files referenced by
local:refs;url:/youtube:are format-checked only. - β
media.kinddisagrees with file extension for local refs (video-as-audio allowed). - β Local media exceeds size cap (100 KB images, 1 MB audio/video).
- β
multiple_choicevariant with zerocorrect: truechoices (checked on the choices array). - β
orderitems with duplicate or non-contiguouspositionvalues (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, numericanswer, variant config) β currently blocked by strict-object schema but not explicitly checked after merge.
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).
These were debated; the design above reflects the chosen path.
- Per-question
default_langwith 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: trueon choices instead of a separate answer key β impossible to desync; overlays cannot edit correctness.- Pack composition via
includesinstead 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 islocal:(wasmedia:in early design β renamed to avoid confusion with thedata/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.
- Community packs β same repo (PR-reviewed) or separate submission
pipeline? Start with same repo under
data/packs/community/. - Versioning β should questions carry a
revisionfield for "this answer was updated"? Probably yes once we hit the first real correction. - RTL languages (Arabic, Hebrew) β schema is fine, UI must support from day one to avoid retrofit pain.
- 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).
- Duplicate-fact detection β two differently-worded questions for the
same fact can co-occur in a pack. Optional future
fact_idfield for loader-level dedup; defer until it actually bites. - 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)?
pnpm new-questionscript β scaffolds a new question entry with a unique slug, required fields, and correct tag categories. Not yet built.- Gamemode manifest validation β gamemode manifests are not parsed/validated
by the Rust data layer yet (only
recommended_gamemodesIDs 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.)
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.
- β
Schema definitions / types (question, question-overlay, pack, pack-overlay,
board, tag, media, common) β Rust, validated by
garde. - β Data loader (parse YAML β validate β build indexes). Cross-file validation: duplicate IDs, dangling refs, tag refs, overlay refs, pack cycles, media existence/kind/size.
- β
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. - β
Pool query engine β
query_pool(filter)with ANDedkinds,tags_all,tags_any,tags_none,variants_any,limit. - β
Board builder β resolves board categories via explicit IDs, pack refs, or
filter queries; deduplicates; deterministic shuffle via
mulberry32PRNG. - β
First gamemode:
grid_quiz(Jeopardy-style) with manifest + board YAML. No runtime wiring yet. - β Game runtime (rooms, WebSocket, scoring) β see
docs/architecture.md. - β Second gamemode (
battle_royaleormusic_quiz) to validate the gamemode-agnostic claim. - β
new-questionscaffolding script. - β Rust JSON Schema export for editor YAML LSP.
- β Deprecated-question warnings in validation.