Course format

A course is a folder of JSON files. The app reads them at start-up (built-in courses under data/courses/<id>/) or from the database (courses built in the app). Both use exactly this format. This page is the complete reference: an LLM given this page can write a valid course. See also: JSON Schema, and a walkthrough for people, How a course is built.

<course>/
  course.json              the manifest (required)
  placement.json           optional placement test (a course with "levels")
  lessons/**/*.json        one lesson per file; folders are free-form
  pictures/**/*.svg        optional SVG pictures a lesson's text can show

Validation runs on every load. A mistake stops a built-in course from starting (the message is in the log) and shows on the editor page of a course built in the app. Every error names the file and the field.

The hierarchy

course
└── units            "A1", "Chapter 1", "Module: Scales"   (manifest.units, ordered)
    └── groups       "Grammar", "Vocabulary", "Basics"      (manifest.groups, free-form ids)
        └── lessons  one JSON file each; the unit of learning

A lesson names its unit and group by id. Units are ordered as listed in the manifest; lessons are ordered inside a unit by order, then title. Mastery, the Map and “current unit” all follow this structure.

course.json

{
  "id": "spanish",                 // lowercase a-z, 0-9, '-'. Built-in: must equal the folder name
  "title": "Spanish",
  "subject": "Spanish",            // optional, defaults to title
  "description": "One or two sentences for the course card.",
  "icon": "🇪🇸",                   // optional emoji
  "terms":    { "label": "Español", "lang": "es" },   // what the vocabulary terms are
  "meanings": { "label": "English", "lang": "en" },   // what their meanings are written in
  "units": [                       // required, at least one, in learning order
    { "id": "A1", "title": "A1", "description": "Beginner" },
    { "id": "A2", "title": "A2", "description": "Elementary" }
  ],
  "levels": true,                  // optional: the units are levels a learner may start at (see "Starting level")
  "language_features": true,       // optional: the language features on or off (see below); unset = on when terms.lang differs from meanings.lang
  "groups": [                      // optional; lessons may use group ids not listed here (shown as-is)
    { "id": "Grammar", "title": "Grammar" },
    { "id": "Vocabulary", "title": "Vocabulary" }
  ],
  "paradigm": { ... },             // optional, see "Paradigm tables"
  "chars": ["á", "é", "í", "ó", "ú", "ñ", "ü", "¿", "¡"],   // optional tap-to-insert characters for text answers
  "calendar": {                    // optional, see "Calendar"
    "days": ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"],
    "months": ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
    "format": "{day}, {d} de {month} de {year}"
  },
  "strings": {                     // optional
    "praise": ["¡Correcto!", "¡Muy bien!"],   // shown on a right answer (one at random)
    "almost": ["Not quite", "Casi…"]          // shown on a wrong answer
  },
  "themes": {                      // optional, see "Themes": praise for a theme, in the course's language
    "realm": { "strings": { "praise": ["¡Victoria!"], "almost": ["Casi… repite la misión"] } }
  }
}

levels says the units are levels of the subject (CEFR A1, A2…; Beginner, Intermediate…) rather than chapters that build on each other. With it, a learner who opens the course for the first time picks the level to start at (they can change it later on the lessons page): the lessons of the levels below are skipped, shown as open but never recommended, practised, reviewed or counted as passed. Leave it out (or false) when every unit needs the ones before it; a course needs at least two units with lessons for the picker to appear. The editor’s gear (Levels) writes this field; with it on, the gear’s Placement test switch adds or takes out placement.json.

terms / meanings label the two sides of every vocabulary entry and flashcard. For a language course the terms are the language being learnt and the meanings the learner’s language. For anything else (music theory, a programming language, anatomy) the terms are the terminology and the meanings their definitions; set lang to "" or the page language. lang is a BCP 47 tag used for lang= attributes and spell-checking.

language_features switches the language features on or off: Listening, Sprint mode’s listening exercises and sentence builds, tap to hear, Numbers by ear, and in the lesson editor the translation fields (a word’s example meaning, a conversation line’s meaning) and the sections only a language course fills. Leave it out and they are on exactly when terms.lang differs from meanings.lang. Set it to false for a course that teaches terms in the learner’s own language and should not ask for translations; set it to true for a course whose terms carry no lang but wants them anyway. The editor’s gear (Language features) writes this field. A lesson’s listening, conversations and builds stay valid either way; they are simply not offered while the features are off.

Paradigm tables

Optional. A paradigm is a table shape shared by the whole course: for Spanish it is the six persons (rows, “slots”) × tenses (columns, “sets”). Lessons can then carry conjugations, which power the Conjugation Drill game and the “ladder” exercise in Sprint mode. Without a paradigm those features are hidden.

"paradigm": {
  "label": "Conjugations",         // heading on lesson pages and in Games
  "item_label": "Verb",            // what one table describes
  "slots": [                       // 2 to 12 rows
    { "key": "yo", "label": "yo", "hint": "I", "who": "me",   // optional: which person the slot is (below)
      "art": ["   |   ", "   |   ", " \\ | / ", "  \\|/  ", "   v   "] },   // optional ASCII picture, ≤ 8 lines × 24 chars
    { "key": "tu", "label": "tú", "hint": "you", "who": "you" }
  ],
  "sets": [                        // 1 or more columns
    { "key": "present", "label": "Present" },
    { "key": "preterite", "label": "Preterite" }
  ]
}

who says which grammatical person a slot is, and the drill draws it with the learner’s profile pictures instead of art: me (I: the learner’s own picture), you (you, one person: another picture), other (he, she: another picture, off to the side), us (we: the learner and two others), you_all (you, several: three others) and them (they: three others, farther away). Leave it out for slots that are not people (cases, scales); they show art.

Slot labels may list alternatives separated by / (e.g. "él / ella / usted"); the drill accepts an answer typed with any of them in front (“ella habla”).

Calendar

Optional. The names of the days of the week and the months in the course’s language, for the Date Dash game (days and months against the clock). Without it the game is hidden. days lists exactly seven names, Monday first; months exactly twelve, January first; both in the usual case of the language (lower-case for Spanish). format says how a full date is written, with {day}, {d} (the day of the month), {month} and {year} placeholders; {day} and {month} are required, the default is "{day}, {d} {month} {year}". The learner’s own names for the days and months come from the browser, in meanings.lang, so they are not listed.

"calendar": {
  "days": ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"],
  "months": ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
  "format": "{day}, {d} de {month} de {year}"      // -> "jueves, 9 de octubre de 2026"
}

Themes

A theme is a skin for the whole app, not part of a course: it lives in data/themes/<id>/theme.json (with its pictures under pictures/), a learner wears it in their profile settings whatever course is open, and it changes the colours of every page. A course never needs a theme. What a course may do is carry material for a theme in its lessons (see “Themed material” below): example sentences, exercises and questions in the theme’s world, shown first while that theme is on; and, in course.json themes.<id>.strings, praise and “not quite” lines for the theme in the course’s language, which win over the theme’s own while it is worn.

A theme file belongs to the app, not to a course, so everything it says (picture names, praise) is in English, or the learner’s app language for a built-in theme, and fits any course; words in the course’s language come from the course. The profile never depends on the course at all (Theming). The app ships three themes: realm (The Realm of Eldmark, an original fantasy world: gold on night, folk portraits for the profile, and the Spanish course has sentences about orcs, mages, dungeons and loot for it), meadowbrook (a cozy original farm town) and rustwake (an original retro-futurist wasteland: terminal green on dark, scavenger portraits, sentences about the Dome, nuts and the radio wrist-watch). Theming is the complete reference for theme files and for everything a course can carry for a theme.

The lesson comes first; a theme is flair. Everything a theme does is declared in JSON files, which are the only source of truth: the app renders what the files declare and nothing more. A lesson may carry material for any number of themes or none; a theme can only add to a lesson, never take its own teaching away; and a course without any themed material is simply shown in the theme’s colours, its content unchanged. Switching theme changes how the site looks and which examples come up first, not what is taught, and switching back restores everything as it was. Design lessons so that they are complete without any theme, then layer the themed material on top.

// data/themes/realm/theme.json
{
  "id": "realm",                            // lowercase a-z, 0-9, '-'; must equal the folder name; lessons refer to it
  "title": "The Realm of Eldmark",
  "description": "Eldmark's colours everywhere, race portraits, and sentences about orcs where a course has them.",
  "icon": "⚔️",
  "colors": {                             // CSS colour tokens, hex only (#rgb, #rrggbb, #rrggbbaa)
    "bg": "#0f0c16", "surface": "#1b1626", "text": "#ead9b0", "muted": "#a89a7b", "line": "#3a2f4a",
    "accent": "#b07d1f", "accent_dark": "#7d5710", "gold": "#f5c542", "gold_dark": "#c99a1e",
    "ok": "#5bb450", "ok_dark": "#3f8f36", "ok_bg": "#16331f", "bad": "#d2382c", "bad_bg": "#3b1a16",
    "warn_bg": "#3a2e12", "sky": "#4d8fe0", "violet": "#a335ee"
  },
  "dark": { "surface": "#1b1626" },       // optional: the same tokens for browsers that prefer a dark scheme
  "font": "'Palatino Linotype', 'Book Antiqua', Palatino, Georgia, serif",   // optional font-family stack
  "art": {                                // optional; files of the theme under pictures/, or https:// URLs
    "banner": "pictures/banner.svg",      // wide picture on the home page and the Sprint mode home
    "mark": "pictures/mark.svg"           // small emblem in the picker and next to the course title
  },
  "strings": { "praise": ["Victory!"], "almost": ["Not quite, adventurer"] },  // optional, in English (a built-in theme's also in i18n/); replace a course's while worn (a course's themes.<id>.strings win)
  "avatars": [                            // optional: the theme's version of the built-in profile pictures, by their id
    { "id": "sofia", "label": "Nym", "image": "pictures/avatars/night-elf.svg" }   // id: one of noor, amani, yuki, kai, arjun, sofia, mochi, coco; label: the picture's name, in English
  ],
  "icons": {                              // optional: the app's own little icons, by slot (an emoji or short glyph each)
    "streak": "🔥", "xp": "💠", "words": "📜", "goal": "👑", "flawless": "💎", "effort": "🛡️",
    "mastery": "⚔️", "locked": "⛓️", "trophy": "🏰", "review": "🔮", "path": "🗺️", "site": "🏰",
    "start": "🧤", "bite": "📜", "hint": "🕯️", "mode_sprint": "🗡️", "mode_study": "📖", "mode_play": "🎲", "mode_guide": "🧭", "mode_ai": "🧙"
  },
  "lesson_icons": ["🛡️", "⚔️", "🏹", "🪄", "🧪", "🗝️"]   // optional: icons for lessons that set none, used in turn along the Sprint mode path
}

The icon slots and what the app shows by default: streak 🔥 (the day streak and a hot run of answers), xp ⚡ (the lightning round), words 📚, goal 🎉 (goal done, session complete), flawless 🌟, effort 💪, mastery 🌶️ (one unit of mastery on the path), locked 🔒, trophy 🏆, review 🔁, path 🗺️, site 🖥️, start 🏁 (the first lesson when it has no icon), bite 💡, hint 💡, and mode_sprint 📱, mode_study 📖, mode_play 🎲, mode_guide 🧭, mode_ai 🤖 for the modes. A slot left out keeps the default; an unknown slot is an error. A lesson’s own icon on the path (sprint.icon) is content and stays unless the lesson’s theme block gives an icon of its own (the waving hand of the greetings lesson becomes a gauntlet in Eldmark, say).

Every token in colors and dark is optional; the ones given override the app’s defaults (both its light and its dark look, so a dark theme is simply one whose colors are dark). Unknown tokens, non-hex colours and anything but a font name list are validation errors, so a theme file can never put CSS of its own into the page. avatars gives the theme’s own version of each built-in profile picture, keyed by the picture’s id: a learner who chose the leaf sees the wood elf while the theme is on and the leaf again when it comes off, and a picture the theme has no version of keeps its plain look. Themes are loaded when the app starts; a broken one stops it with the file and field named.

placement.json (placement test)

Optional, one per course, for a course with "levels": true (with fewer than two units that have lessons there is nothing to place, and the test is not offered). A learner who opens the course for the first time can take it at the end of the start wizard instead of picking a level. Every question is multiple choice and about one unit:

{
  "random_per_unit": 3,            // optional, 0 to 10: questions drawn at random per unit (below)
  "questions": [                   // optional: written questions
    {
      "unit": "A1",                // a unit id from course.json
      "prompt": "¿Cómo te llamas? means…",
      "options": ["What is your name?", "How are you?", "Where do you live?"],   // at least 2, none twice
      "answer": "What is your name?"   // one of the options
    }
  ]
}

A unit with written questions is tested with those. Every other unit draws random_per_unit of its own lessons’ multiple-choice (and true-false) quiz questions at random each time the test is taken. A unit with neither is not tested. The file needs at least one written question or a random_per_unit above 0. The options are shown shuffled.

Marking: a unit answered all right can be skipped; the first unit with a wrong answer is where the test suggests starting. With every answer right, it suggests the unit after the last one tested (or the last unit). The suggestion only picks a level in the wizard: nothing is marked passed, as with any starting level.

Lesson files

{
  "id": "a1-ser-vs-estar",          // unique in the course; referenced by prerequisites
  "slug": "ser-vs-estar",           // URL: /lessons/ser-vs-estar  (lowercase a-z, 0-9, '-')
  "title": "Ser vs. Estar",
  "unit": "A1",                     // a unit id from the manifest (required)
  "group": "Grammar",               // a group id; defaults to "general"
  "order": 20,                      // sort key inside the unit, lower first (default 100)
  "summary": "One line for lists.",
  "focus": "Choosing between ser and estar",   // the one thing the lesson teaches
  "prerequisites": ["a1-regular-ar-present"],  // lesson ids, optional
  "content": "## Heading\n\nMarkdown…",        // required; see "Content"
  "vocabulary": [ ... ],            // see below
  "quiz": [ ... ],                  // see below
  "conjugations": [ ... ],          // needs manifest.paradigm
  "conversations": [ ... ],         // dialogues; Sprint mode builds sentences from their lines
  "listening": [ ... ],             // hear it, type it (language courses)
  "guide": [ ... ],                 // optional hand-ordered Guide steps
  "sprint": { ... },                 // optional phone-session extras
  "themes": { "realm": { ... } }      // optional themed material, by theme id; see "Themed material"
}

Content

content is Markdown. Tables, lists, bold, code blocks and blockquotes are supported. Raw HTML is not: it is shown as text. Links may use relative, http(s):// or mailto: URLs only; any other scheme points nowhere. Images load only from a relative URL or a data:image/… URL; an image from another host is shown as a plain link. Every ## heading starts a new section; text before the first heading becomes an “Introduction” section. Sections are the cards of the Guide and the “bites” of Sprint mode when none are written by hand, so keep each one short and about one thing. Three to five sections is a good lesson.

Music notation

A fenced code block marked abc is music notation, written in ABC notation (plain text, a few header lines and then the notes). The lesson page and the Guide draw it as a staff where the block stands; other modes show the ABC text as a code block.

```abc
X:1
T:C major scale
M:4/4
L:1/4
K:C
C D E F | G A B c |]
```

Start each block with X: and end the header with K: (the key). One tune per block; keep it short enough to read on a phone (two to four bars). Put what the learner should notice in the text around it, not in the ABC. A lesson without an abc block looks as it always did.

Vocabulary

The terminology of the lesson. Entries are collected by the learner when they answer a question that teaches them, then studied as flashcards, played in Word Match and in Sprint mode.

"vocabulary": [
  { "term": "hola", "meaning": "hello",
    "example": "¡Hola! ¿Qué tal?", "example_meaning": "Hello! How are you?" }   // example optional; give it a meaning
]

Terms must be unique within a lesson. An example should come with its example_meaning in the meaning language: it is shown as a hint when the learner hovers the example (on the lesson page, the flashcards and the Vocabulary page) and after a Sprint mode vocabulary exercise.

Quiz

"quiz": [
  {
    "question_id": "a1-ser-vs-estar-01",   // unique across the whole course: prefix with the lesson id
    "prompt": "Mi madre ___ médica.",
    "type": "multiple-choice",             // or "fill-in-blank", "true-false", "numeric", "expression"
    "options": ["es", "está"],             // multiple-choice: 2+ options containing correct_answer; shuffled per quiz
    "correct_answer": "es",
    "accepted_answers": ["ella es"],       // fill-in-blank, numeric, expression: other answers that count as right
    "explanation": "Occupation takes ser.",
    "hints": ["Is a profession an identity or a state?"],
    "teaches": ["ser"]                     // vocabulary terms (of this lesson) collected on a correct answer
  }
]

Five question types, chosen to fit the subject:

typeThe learner…correct_answerChecked
multiple-choicetaps one of optionsone of the optionsexactly
true-falsetaps True or False"true" or "false"exactly (served as a two-option choice; no options needed)
fill-in-blanktypes a word or phrase (Sprint mode offers word tiles)the textignoring case, surrounding punctuation and extra spaces; a missing accent is a near miss
numerictypes a number (Sprint mode offers number tiles)a numberby value, see below
expressiontypes a formulaan algebraic expressionby equivalence, see below

Fill-in-blank answers are checked case-insensitively, ignoring surrounding punctuation and extra spaces. A vocabulary term equal to an accepted answer is collected automatically; teaches lists any others. Write ___ in the prompt where the answer goes: Sprint mode shows it as a slot to fill from word tiles. A fill-in-blank has one answer and so one ___. A sentence with two gaps is a multiple-choice question whose options fill both ("más … que").

Maths answers. numeric and expression questions are graded by value, so the learner can write the answer any reasonable way:

{ "question_id": "q-frac-01", "prompt": "Write 3/4 as a decimal.", "type": "numeric", "correct_answer": "0.75" },
{ "question_id": "q-circle-01", "prompt": "Area of a circle of radius 2, to 2 decimals?", "type": "numeric",
  "correct_answer": "12.57", "tolerance": 0.01, "unit": "cm²" },
{ "question_id": "q-roots-01", "prompt": "Solve x² + x − 6 = 0.", "type": "numeric", "correct_answer": "2; -3",
  "explanation": "(x + 3)(x − 2) = 0, so x = −3 or x = 2." },
{ "question_id": "q-expand-01", "prompt": "Expand (x + 1)².", "type": "expression", "correct_answer": "x^2 + 2x + 1" }
  • numeric: 3/4, 0.75, 1 1/2, 1,250, 2.5e3, 40%, 2^10, sqrt(2) and x = 4 are all read as numbers. Without tolerance the value must be exact (up to floating-point noise); with it, anything within that margin counts (0.01 for “to two decimals”). unit is shown after the answer box and may be typed or not. Values separated by ; make a list answered in any order (2; -3 accepts -3, 2, x = 2 or x = -3). A wrong answer within 1 % gets a “check your rounding” note, the right number with the wrong sign a “check the sign” note.
  • expression: an answer is right when it is equivalent to correct_answer: 2(x+1) for 2x + 2, (x-1)(x+1) for x^2 - 1. Write it with + - * / ^, brackets, implicit multiplication (2x, 3(x+1), xy), sqrt abs exp ln log sin cos tan asin acos atan, pi and e (so e is never a variable); variables are single letters. A leading y = is ignored. The look-alikes from phones and copy-paste (−, ×, ÷, ², ³, √, π) work too. If the question wants a particular form (“factor completely”), ask for it with multiple-choice instead: equivalent forms are all accepted.
  • Both: an answer (expected or typed) is at most 200 characters and 64 levels deep (brackets, signs, powers, function calls, terms in a row); longer or deeper ones are rejected as unreadable.

Write maths in prompts, content and explanations as plain text or Unicode (x², √2, π, ×, ≤), not LaTeX. A maths course should set both terms.lang and meanings.lang to the learner’s language (or leave them empty) so the language games stay off.

Each quiz question feeds the lesson’s mastery score (right answers build it up with diminishing returns, wrong ones take 35 % off, and it decays slowly after three days without practice).

Conjugations (paradigm tables)

Only when the manifest has a paradigm. forms must have a value for every slot key. An item + set pair may be taught by one lesson only. Each distinct item needs an art in one lesson (other lessons teaching the same item in another set inherit it); it is optional otherwise.

"conjugations": [
  { "item": "hablar", "meaning": "to speak", "set": "present",
    "art": [" .--------. ", "( ¡hola!   )", " `---. ,--' ", "      V     "],
    "forms": { "yo": "hablo", "tu": "hablas", "el": "habla",
               "nosotros": "hablamos", "vosotros": "habláis", "ellos": "hablan" } }
]

Conversations

A scripted two-person exchange. In Sprint mode its lines of three to nine words become sentence builds. meaning is the line in the learner’s language; it may be omitted for courses where that makes no sense (such a line is not built).

"conversations": [{
  "id": "a1-greetings-conv-first-meeting",   // unique across the course
  "title": "First meeting",
  "scenario": "Ana and Carlos bump into each other on the first day of a course.",
  "role_a": "Ana", "role_b": "Carlos",
  "lines": [                                 // at least 2, both roles must speak
    { "speaker": "a", "text": "¡Buenos días!", "meaning": "Good morning!" },
    { "speaker": "b", "text": "Buenos días. ¿Qué tal?", "meaning": "Good morning. How's it going?",
      "note": "optional pointer shown under the line" }
  ]
}]

Listening (language courses)

Sentences for listening practice: the browser reads one aloud in the course’s language (its own speech synthesis, in terms.lang), the learner types what they heard and sees it checked word by word. Sprint mode draws its listening exercises from the same sentences (rebuilt from tiles, with a gap to fill, or heard and matched to its meaning). Only language courses (language_features, or a terms.lang that differs from meanings.lang) offer them; a lesson without listening simply does not appear there.

"listening": [
  { "text": "Mi madre es médica.", "meaning": "My mother is a doctor.", "note": "Occupation takes ser." },
  { "text": "Los niños están en la oficina.", "meaning": "The children are at the office.",
    "note": "New word: la oficina = the office." }
]

text is the sentence exactly as it should be typed (1 to 30 words; checked case-insensitively, ignoring punctuation, with accent slips flagged rather than counted wrong). meaning is its translation, shown after answering. note is optional: a one-line pointer at the grammar, or the new word the sentence slips in, written New word: <word> = <meaning>. so the app can highlight it. Eight sentences per lesson, short to long, is a good set; most words should come from the lesson’s own vocabulary, with one new word in two or three of them to stretch the learner. Write numbers as words, since the learner types what they hear. Texts must be distinct within a lesson.

Guide (optional)

The Guide plays a lesson as a sequence of small steps: concept cards, quick checks, flashcard hands, tips. Without a guide array the steps are derived: every section becomes a card, vocabulary is dealt in hands of five after the first card, and questions are interleaved from the third card on. To order the steps by hand, reference the lesson’s own content:

"guide": [
  { "section": "Use ser" },                  // a section, matched by heading prefix (case-insensitive)
  { "question": "a1-ser-vs-estar-01" },      // a quiz question of this lesson, by id
  { "vocab": 4 },                            // the next four vocabulary entries as flashcards
  { "tip": "Still unsure? Ask: what it IS, or how it is right now?" },
  { "concept": { "title": "…", "markdown": "…" } }   // an extra card that exists only here
]

A reference to an unknown section or question is a validation error.

Sprint mode (optional)

Sprint mode is the phone-first, five-minute session: one bite, then rule games, quiz questions and vocabulary games, interleaved. Everything below is optional; a lesson without a sprint block still plays (bites come from the sections, the rest from the quiz, vocabulary, conjugations and conversations).

"sprint": {
  "icon": "🎭",                              // emoji for the lesson's stop on the path; different from every other lesson's
  "bites": [                                 // the idea in one breath; not graded
    { "title": "Ser = what it is", "emoji": "🪪", "rule": "Identity, origin, profession, time: ser.",
      "example": "Soy médica.", "example_meaning": "I am a doctor." }
  ],
  "sorts": [                                 // tap each item into the right bucket; 2–4 buckets, 3–10 items
    { "title": "Ser or estar?", "buckets": ["ser", "estar"],
      "items": [ { "text": "Soy de Madrid.", "bucket": 0 }, { "text": "Estoy cansado.", "bucket": 1 }, { "text": "Es médica.", "bucket": 0 } ],
      "why": "Origin and profession take ser; a temporary state takes estar." }
  ],
  "spots": [                                 // tap the one wrong word
    { "sentence": "Mi madre está médica.", "wrong": "está", "fix": "es", "why": "Profession takes ser." }
  ],
  "builds": [                                // rebuild the text from shuffled word tiles; 2–12 words
    { "text": "Mi madre es médica.", "meaning": "My mother is a doctor.", "distractors": ["está"] }
  ]
}

Rules checked by the validator: a spot’s wrong word must appear exactly once in its sentence (punctuation is ignored); a build’s text has 2 to 12 words; every sort item points at an existing bucket.

Which games fit depends on the kind of course. In a language course, spots catch a wrong word in a sentence of the language and builds rebuild its sentences. In any other course, sorts are the main game, builds are left out, and a spot only makes sense where swapping one key term makes a statement false (“Exiles protect the system from pain”: wrong Exiles, fix Managers), never for grammar, spelling or an everyday word.

Themed material (optional)

Themed material is shown in the built-in courses only, for now: a course made in the app (by hand or with AI) is always served plain, and its theme blocks are validated but not shown. Generated courses never get any.

A lesson may carry material for any theme of the app (see “Themes” above), under themes.<id>. The lesson’s own fields stay the teaching; the themed block is flair in the theme’s world, written with the same shapes as the fields it mirrors. A lesson with no block for a theme simply shows its plain material when that theme is worn, so a theme never needs to cover every lesson (nor every course). Every theme block is validated with the course whether or not anyone wears the theme (errors name the file and the theme); a block for a theme this installation does not have is kept and never shown.

"themes": {
  "realm": {
    "icon": "🧤",               // the lesson's stop on the Sprint mode path while the theme is on (instead of sprint.icon)
    "content": "## Ser y estar en Eldmark\n\nYour character **es** an orc…",   // Markdown appended as extra section(s)
    "vocabulary": [
      { "term": "ser", "example": "Soy un guerrero de la Hueste de Ceniza.", "example_meaning": "I'm a warrior of the Ash Host." },   // a term of the lesson: only its example changes
      { "term": "la mazmorra", "meaning": "dungeon", "example": "La mazmorra está lejos.", "example_meaning": "The dungeon is far away." }   // a new term: needs a meaning, added at the end
    ],
    "quiz": [ ... ],            // same shape as the lesson's quiz; ids must be unique across the course (suffix them, e.g. "-realm-01")
    "listening": [ ... ],       // same shape as listening
    "conversations": [ ... ],   // same shape as conversations; ids unique across the course
    "bites": [ ... ], "sorts": [ ... ], "spots": [ ... ], "builds": [ ... ]   // same shapes as the sprint fields
  }
}

When the theme is on, the merged lesson is: themed bites, sentences, exercises, questions and conversations first, then the lesson’s own (so anything that takes “the first few” shows the theme first and falls back to the lesson); vocabulary with the themed examples swapped in and new terms added after the lesson’s; the themed Markdown as extra sections after the lesson’s. A themed icon should differ from every other stop on the path while that theme is worn: from the other lessons’ icons for the same theme and from the plain sprint.icon of lessons without one, and from the theme’s locked icon, which a locked stop shows (built-in courses are tested for it, as for the plain icons). A themed vocabulary entry for a term the lesson already teaches may not change its meaning, and a new term must give one. Themed questions count towards mastery like any other; themed terms are collected like any other.

Field aliases

Older Spanish-specific spellings still load, so existing files keep working: level → unit, category → group, target_grammar_point → focus, markdown_content → content, journey → guide, es/en → term/meaning (vocabulary), text/meaning (lines and builds), example_es/example_en → example/example_meaning (bites and vocabulary), verb/tense → item/set, dmode → sprint. New files should use the canonical names shown above.

Which feature reads what

Lesson fieldUsed by
content (split on ## )Lesson viewer, Guide cards, Sprint mode bites (when none are written)
content ```abc blocksMusic notation drawn as a staff on the lesson page and Guide cards
quizPractice quizzes, lesson quiz, Guide quick checks, Sprint mode, mastery scores
quiz[].teaches + vocabularyVocabulary collection and flashcards
vocabularyGuide flashcard hands, Vocabulary page, Word Match, Sprint mode pick/match and its listening exercises (what do you hear, select the matching audio, listen and match)
conjugationsLesson table, Conjugation Drill, Sprint mode ladders
conversationsSprint mode sentence builds
listeningListening practice (hear it, type it), Sprint mode listening exercises (tap / fill in what you hear, what does it mean)
guideHand-ordered Guide steps
sprintSprint mode bites and sort / spot / build exercises
themes.<id>Every feature above, merged in front of the lesson’s own material while that theme is worn
prerequisites, unit, orderLesson order, quiz weighting, Map, Guide stage order

placement.json (course-level) is read by the start wizard’s placement test; its random_per_unit draws from the lessons’ multiple-choice quiz.

A lesson simply does not appear in a feature whose data it lacks (Word Match needs at least 2 vocabulary entries, the drill needs conjugations, Listening needs listening sentences).

A minimal valid course

// course.json
{ "id": "knots", "title": "Knots", "units": [ { "id": "1", "title": "Basics" } ] }

// lessons/bowline.json
{
  "id": "k-bowline", "slug": "bowline", "title": "The bowline", "unit": "1",
  "content": "## What it is\n\nA fixed loop that will not slip.\n\n## Tying it\n\nRabbit out of the hole, round the tree, back down the hole.",
  "vocabulary": [ { "term": "bight", "meaning": "a U-shaped bend in the rope" }, { "term": "standing part", "meaning": "the rope that leads away from the knot" } ],
  "quiz": [ { "question_id": "k-bowline-01", "prompt": "A bowline makes a loop that…", "type": "multiple-choice",
              "options": ["does not slip", "tightens under load"], "correct_answer": "does not slip", "explanation": "It is a fixed loop." } ]
}