Version 1.0 Specification
KQF is a plain-text, Markdown-compatible format for authoring interactive quiz content. It is human-readable, diff-friendly, and trivially machine-parseable with a line-by-line or regex-based parser. Files use the .kqf extension.
A .kqf file has two sections:
[FRONT MATTER] ← YAML block between --- delimiters
[QUESTIONS] ← One or more question blocks separated by ---
Delimited by --- at the top of the file. Standard YAML.
---
title: World Geography Quiz
description: Test your knowledge of world geography!
author: Jane Doe
version: 1.0
language: en
tags: [geography, world, capitals]
---| Field | Type | Required | Description |
|---|---|---|---|
title |
string | ✅ | Quiz display name |
description |
string | ❌ | Short quiz description |
author |
string | ❌ | Quiz author name |
version |
string | ❌ | Quiz version (semver recommended) |
language |
ISO 639-1 code | ❌ | Quiz language (en, fr, de…) |
tags |
string[] | ❌ | Searchable tags |
Questions are separated by --- (horizontal rules). Each block has:
- Question Header — an ATX heading (
##) with metadata - Question Text — one or more plain text lines
- Answer Content — depends on question type
- Media Directives — optional
@key: valuelines
## {ID} | {type} | {time} | {points}
| Token | Format | Required | Example |
|---|---|---|---|
ID |
alphanumeric slug | ✅ | Q1, q-3 |
type |
see types below | ✅ | singlechoice |
time |
integer + s |
❌ | 30s |
points |
integer + pts |
❌ | 1000pts; if omitted (or 0), score weight is 1 |
Parseable regex:
^## (?P<id>[^\|]+)\s*\|\s*(?P<type>[^\|]+)\s*(?:\|\s*(?P<time>\d+s))?\s*(?:\|\s*(?P<points>\d+pts))?$Exactly one correct answer. Correct answer marked with [x], wrong with [ ].
## Q1 | singlechoice | 30s | 1000pts
What is the capital of France?
- [x] Paris
- [ ] Lyon
- [ ] Marseille
- [ ] NiceRules:
- Minimum 2 answers, maximum 6
- Exactly 1
[x]required
One or more correct answers.
## Q2 | multichoice | 45s | 500pts
Which of the following are countries in Scandinavia?
- [x] Norway
- [x] Sweden
- [x] Denmark
- [ ] Finland
- [ ] GermanyRules:
- Minimum 2 answers, maximum 8
- At least 1
[x]required
Binary true/false question.
## Q3 | truefalse | 15s | 500pts
Australia is both a country and a continent.
- [x] True
- [ ] FalseRules:
- Exactly 2 answers:
TrueandFalse(case-insensitive) - Exactly 1
[x]required - No other answer options permitted
Numeric range answer with tolerance. Uses a @slider: directive block instead of list items.
## Q4 | slider | 60s | 1000pts
In what year did the Berlin Wall fall?
@slider:
correct: 1989
min: 1950
max: 2000
step: 1
tolerance: 2
unit: year@slider fields:
| Field | Type | Required | Description |
|---|---|---|---|
correct |
number | ✅ | The exact correct value |
min |
number | ✅ | Minimum slider value |
max |
number | ✅ | Maximum slider value |
step |
number | ❌ | Increment step (default: 1) |
tolerance |
number | ❌ | ±range still considered correct (default: 0) |
unit |
string | ❌ | Display unit label (%, km, year, etc.) |
Scoring with tolerance: A response r is correct if |r - correct| <= tolerance.
Optional, placed after answer content. Each directive is a single line:
@{key}: {value}
| Directive | Description | Value type |
|---|---|---|
@image |
Display image with question | URL, path to file, or relative media/... asset directory (last path segment has no extension; on disk that folder holds image.webp + thumb.webp) |
@video |
Autoplay video before/during question | URL or path |
@audio |
Play audio clip | URL or path |
@hint |
Optional hint shown to players | Plain text |
Multiple media directives may be used per question. Order determines render priority.
Examples:
@image: https://cdn.example.com/map.jpg
@audio: ./sounds/intro.mp3
@hint: Think about the Cold War era.
For image-based answer options, @image: may also appear immediately after a - [x] / - [ ] line — the directive then attaches to that choice instead of the question. Only @image is allowed at the choice level (no @video, @audio, @hint). Path resolution and asset-directory semantics are identical to question-level @image.
## Q | singlechoice | 20s | 1000pts
Which flag is Poland's?
- [x] (option A)
@image: ./media/asset_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
- [ ] (option B)
@image: ./media/asset_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbA choice may have text or image or both — the editor enforces that at least one is present.
Asset directory: for paths under media/ whose last segment has no file extension, the value is stored as the folder path (no /image.webp suffix in the parsed model). On disk that folder still contains image.webp and thumb.webp (admin export layout). Examples:
@image: ./media/asset_cc0b501288944523b06ae7d26cf078ab
@image: ./media/asset_cc0b501288944523b06ae7d26cf078ab/
http(s) URLs and non-asset paths (e.g. dog.jpg) are stored unchanged. Any non-URL value ending in /image.webp or /thumb.webp (e.g. ./media/{asset_…}/image.webp) is invalid in KQF text — use the asset directory form above. http(s) URLs ending in those filenames remain valid.
Play session join: kqf_with_absolute_media turns relative media/... into absolute URLs under {origin}/media/{quiz_id}/.... If the URL’s last path segment has no extension, the join result is the asset base URL (no trailing /image.webp); the participant app then appends /image.webp and /thumb.webp once when building image props (resolveKqfPlayImageUrls in frontend/lib/media-url.ts).
Runtime loading (participant app): ProgressiveQuizImage loads thumbUrl first when it differs from fullUrl, then swaps to fullUrl after the full image is loaded in memory. resolveKqfPlayImageUrls builds fullUrl/thumbUrl from the join URL (asset directory → append /image.webp and /thumb.webp once).
file = front_matter , { question_block } ;
front_matter = "---\n" , yaml_content , "---\n" ;
question_block = separator , header , "\n" , question_text , "\n" , answers , { directive } ;
separator = "---\n" ;
header = "## " , id , " | " , type , [ " | " , time ] , [ " | " , points ] , "\n" ;
id = /[A-Za-z0-9_-]+/ ;
type = "singlechoice" | "multichoice" | "truefalse" | "slider" ;
time = /\d+s/ ;
points = /\d+pts/ ;
question_text = { text_line } ;
answers = choice_list | slider_block ;
choice_list = { choice_item } ;
choice_item = "- [" , marker , "] " , text_line , [ choice_image ] ;
choice_image = "@image: " , directive_value , "\n" ;
marker = "x" | " " ;
slider_block = "@slider:\n" , { " " , slider_field , "\n" } ;
slider_field = ( "correct" | "min" | "max" | "step" | "tolerance" ) , ": " , number
| "unit" , ": " , string ;
directive = "@" , directive_key , ": " , directive_value , "\n" ;
directive_key = "image" | "video" | "audio" | "hint" ;- Split file on
---lines to get front matter + question chunks - Parse front matter as YAML
- For each question chunk:
- Match first line against header regex
- Lines before first
- [or@slider:are question text (trim) - Lines matching
- [x]/- [ ]are answer options @slider:block: read indentedkey: valuelines until indent breaks- Lines matching
@key: valueafter answers are directives
- Validate per-type constraints (correct count, slider fields, etc.)
- For
@image, relativemedia/...values with no filename on the last segment are stored as the asset directory path (no/image.webpin the model); see Media Directives above.
---
title: Science Trivia
author: Quiz Bot
tags: [science, beginner]
---
## Q1 | singlechoice | 20s | 1000pts
What is the chemical symbol for water?
- [x] H2O
- [ ] CO2
- [ ] O2
- [ ] H2
@image: https://example.com/water.jpg
---
## Q2 | truefalse | 10s | 500pts
Sound travels faster than light.
- [ ] True
- [x] False
---
## Q3 | slider | 30s | 800pts
What is the boiling point of water in Celsius at sea level?
@slider:
correct: 100
min: 50
max: 150
step: 1
tolerance: 0
unit: °C
---
## Q4 | multichoice | 40s | 600pts
Which of these are noble gases?
- [x] Helium
- [x] Neon
- [ ] Oxygen
- [x] Argon
- [ ] Nitrogen
@hint: They're in the rightmost column of the periodic table.- Markdown-native: Renders readably in any Markdown viewer
- Line-oriented: Each meaningful token is on its own line — no inline state
- Fail-fast: Invalid constraints (e.g. two
[x]insinglechoice) should error at parse time, not runtime - Extensible: New types or directives can be added without breaking existing parsers (unknown types/directives are skipped)
- Diffable: Plain text means version control works perfectly