A quiz file holds rules and a selection of questions. The questions themselves live in bank files and are pulled in by reference.
Quiz files
Each quiz lives in quizzes/<quiz-id>.quiz.json.
{
"schema_version": 1,
"id": "final-test",
"type": "quiz",
"mode": "test",
"instructions_md": "Shown before the learner starts.",
"grading": { "pass_mark_percent": 70, "score_policy": "best", "essay_gate": "wait_for_review" },
"attempts": { "max": 3, "cooldown_minutes": 5 },
"timing": { "limit_minutes": 20, "grace_seconds": 10, "sync_interval_seconds": 15, "on_expire": "auto_submit" },
"delivery": { "shuffle_questions": true, "shuffle_options": true, "one_per_page": true, "allow_back": true },
"feedback": { "timing": "after_submit", "show_correct_answers": true, "show_rationale": true },
"selection": [
{ "kind": "fixed", "bank": "final-pool", "title": "Core", "questions": ["p1", "p2"] },
{ "kind": "random", "bank": "final-pool", "title": "Random", "count": 4, "filter": { "difficulty": ["easy", "medium"] } }
]
}
schema_version, id, type ("quiz"), and selection are required. Everything else is optional and takes the defaults below.
| Group | Key | Values (default) | Meaning |
|---|---|---|---|
mode |
quiz, test |
A label only. | |
instructions_md |
Markdown | Shown before starting. | |
grading |
pass_mark_percent |
0 to 100, or null (null) |
With null, any submission completes the quiz. |
score_policy |
best, latest, average (best) |
Which attempts count for completion and unlock rules. | |
essay_gate |
wait_for_review, auto_only (wait_for_review) |
Hold the result until essays are graded, or judge on auto-graded questions only. | |
attempts |
max |
1 to 100, or null (unlimited) |
|
cooldown_minutes |
0 to 525,600 (0) |
Wait between attempts. | |
timing |
limit_minutes |
1 to 1,440, or null (untimed) |
Enforced by the server clock. |
grace_seconds |
0 to 120 (10) |
Allowance for network lag. | |
sync_interval_seconds |
5 to 120 (15) |
How often answers are saved and the clock re-synced. | |
on_expire |
auto_submit |
Saved answers are submitted when time runs out. | |
delivery |
shuffle_questions |
Boolean (false) |
|
shuffle_options |
Boolean (false) |
||
one_per_page |
Boolean (false) |
||
allow_back |
Boolean (true) |
Only used with one_per_page. |
|
feedback |
timing |
immediate, after_submit, after_final_attempt, never (after_submit) |
With immediate, learners check each answer as they go, and a checked answer locks. |
show_correct_answers |
Boolean (true) |
||
show_rationale |
Boolean (true) |
Shows each question’s rationale_md. |
Selection
selection is a list of 1 to 50 pulls, asked in order:
- Fixed:
{ "kind": "fixed", "bank": "<bank-id>", "questions": ["q1", "q2"] }asks exactly those questions (1 to 200). Every ID must exist in the bank. - Random:
{ "kind": "random", "bank": "<bank-id>", "count": 5, "filter": { ... } }drawscountquestions (1 to 200) on each attempt. The optionalfiltercan requiretags(all listed),difficulty(any listed),types(any listed), andobjectives(all listed).
Both kinds accept an optional title, up to 80 characters, shown as a section heading. A question is never asked twice in one attempt, and a random pull must be satisfiable: its count can’t exceed the matching questions left after its filter and earlier pulls.
Bank files
Each bank lives in banks/<bank-id>.bank.json.
{ "schema_version": 1, "id": "git-basics", "title": "Git basics", "description": "Optional.", "questions": [ ] }
schema_version, id, and questions (up to 1,000) are required. title is up to 120 characters, and description up to 500.
Fields on every question
| Field | Required | Rules |
|---|---|---|
id |
Yes | A local ID, unique in the bank. |
type |
Yes | One of the ten types below. |
stem_md |
Yes | The question, in Markdown, up to 5,000 characters. Never put the answer in it. |
points |
No | Greater than 0, up to 1,000. Default 1. |
difficulty |
No | easy, medium, or hard. |
bloom |
No | remember, understand, apply, analyze, evaluate, or create. |
tags |
No | Up to 20 strings. |
objectives |
No | Up to 20 objective IDs from course.json. |
rationale_md |
No | The explanation shown after answering. Always write one. |
hint_md |
No | Up to 1,000 characters. |
media |
No | { "image": "assets/...", "alt": "..." }. |
source_ref |
No | Where the fact comes from, up to 200 characters. |
The ten question types
single_choice
2 to 10 options, with exactly one "correct": true. Each option can have feedback_md.
{ "id": "sc1", "type": "single_choice", "stem_md": "Which command saves a snapshot?",
"options": [
{ "id": "a", "text_md": "`git commit`", "correct": true },
{ "id": "b", "text_md": "`git init`", "correct": false, "feedback_md": "That creates a repository." } ],
"rationale_md": "Commit records the staged snapshot." }
multi_select
2 to 10 options, at least one correct. scoring is all_or_nothing (default) or partial, which gives credit per correct pick, minus per wrong pick, never below zero.
{ "id": "ms1", "type": "multi_select", "stem_md": "Which are branch commands? Select all that apply.", "scoring": "partial",
"options": [ { "id": "a", "text_md": "`git switch -c x`", "correct": true }, { "id": "b", "text_md": "`git init`", "correct": false },
{ "id": "c", "text_md": "`git merge x`", "correct": true } ] }
true_false
{ "id": "tf1", "type": "true_false", "stem_md": "Git stores snapshots.", "answer": true }
short_answer
accepted is 1 to 30 strings. match is normalized (default: trims and collapses spaces, but is case-sensitive), case_insensitive, exact, or regex (a PCRE pattern without delimiters, such as ^git\s+init$, with (?i) for case-insensitive).
{ "id": "sa1", "type": "short_answer", "stem_md": "Which command creates a repository?", "match": "case_insensitive", "accepted": ["git init", "init"] }
numeric
answer is a number. Optional: tolerance of { "mode": "absolute" | "percent", "value": 0 }, and a unit up to 20 characters.
{ "id": "nu1", "type": "numeric", "stem_md": "3 commits plus 2 more is how many?", "answer": 5, "tolerance": { "mode": "absolute", "value": 0 }, "unit": "commits" }
essay
Graded by hand. Optional: min_words, max_words, and grader_notes_md, which only reviewers see.
{ "id": "es1", "type": "essay", "stem_md": "Explain why teams use branches.", "points": 5, "min_words": 30, "max_words": 300, "grader_notes_md": "Look for: parallel work, isolation, safe merging." }
cloze
Fill in the blanks. text_md contains one {{blank-id}} marker per blank, and blanks defines 1 to 20 blanks. A select blank has 2 to 10 options with exactly one correct. A text blank has accepted answers and an optional match, as in short answer. scoring is all_or_nothing (default) or per_blank.
{ "id": "cl1", "type": "cloze", "stem_md": "Complete the sentence.", "text_md": "Run {{b1}} to stage and {{b2}} to save.", "scoring": "per_blank",
"blanks": [
{ "id": "b1", "kind": "select", "options": [ { "id": "a", "text": "git add", "correct": true }, { "id": "b", "text": "git push", "correct": false } ] },
{ "id": "b2", "kind": "text", "match": "case_insensitive", "accepted": ["git commit", "commit"] } ] }
ordering
items (2 to 12) listed in the correct order. The server shuffles them. scoring is all_or_nothing (default) or partial.
{ "id": "or1", "type": "ordering", "stem_md": "Put the steps in order.", "scoring": "partial",
"items": [ { "id": "s1", "text_md": "Edit a file" }, { "id": "s2", "text_md": "`git add`" }, { "id": "s3", "text_md": "`git commit`" } ] }
matching
left (2 to 10), right (2 to 12, extras act as distractors), and pairs. Every left item needs exactly one pair, and no two left items can share a right item. scoring is all_or_nothing (default) or per_pair.
{ "id": "ma1", "type": "matching", "stem_md": "Match command to effect.", "scoring": "per_pair",
"left": [ { "id": "l1", "text_md": "`git init`" }, { "id": "l2", "text_md": "`git merge x`" } ],
"right": [ { "id": "r1", "text_md": "Starts a repository" }, { "id": "r2", "text_md": "Combines a branch" }, { "id": "r3", "text_md": "Deletes history" } ],
"pairs": [ { "left": "l1", "right": "r1" }, { "left": "l2", "right": "r2" } ] }
categorize
categories (2 to 8) and items (2 to 20), where each item names its category. scoring is all_or_nothing (default) or per_item.
{ "id": "ca1", "type": "categorize", "stem_md": "Sort the commands.", "scoring": "per_item",
"categories": [ { "id": "save", "title": "Saving work" }, { "id": "branch", "title": "Branching" } ],
"items": [ { "id": "i1", "text_md": "`git commit`", "category": "save" }, { "id": "i2", "text_md": "`git merge`", "category": "branch" } ] }
Writing good questions
- Test one clear skill per question, and tie it to an objective.
- Make distractors plausible and about as long as the right answer. Avoid “all of the above.”
- Never make the right answer always the first or the longest option.
- Give every question an explanation that says why, and per-option feedback for tempting wrong answers.
- List every accepted spelling for short answers.
- For random pools, write at least twice as many questions as one attempt draws.