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:
type | The learner… | correct_answer | Checked |
|---|---|---|---|
multiple-choice | taps one of options | one of the options | exactly |
true-false | taps True or False | "true" or "false" | exactly (served as a two-option choice; no options needed) |
fill-in-blank | types a word or phrase (Sprint mode offers word tiles) | the text | ignoring case, surrounding punctuation and extra spaces; a missing accent is a near miss |
numeric | types a number (Sprint mode offers number tiles) | a number | by value, see below |
expression | types a formula | an algebraic expression | by 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)andx = 4are all read as numbers. Withouttolerancethe value must be exact (up to floating-point noise); with it, anything within that margin counts (0.01for “to two decimals”).unitis shown after the answer box and may be typed or not. Values separated by;make a list answered in any order (2; -3accepts-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 tocorrect_answer:2(x+1)for2x + 2,(x-1)(x+1)forx^2 - 1. Write it with+ - * / ^, brackets, implicit multiplication (2x,3(x+1),xy),sqrt abs exp ln log sin cos tan asin acos atan,piande(soeis never a variable); variables are single letters. A leadingy =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 field | Used by |
|---|---|
content (split on ## ) | Lesson viewer, Guide cards, Sprint mode bites (when none are written) |
content ```abc blocks | Music notation drawn as a staff on the lesson page and Guide cards |
quiz | Practice quizzes, lesson quiz, Guide quick checks, Sprint mode, mastery scores |
quiz[].teaches + vocabulary | Vocabulary collection and flashcards |
vocabulary | Guide 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) |
conjugations | Lesson table, Conjugation Drill, Sprint mode ladders |
conversations | Sprint mode sentence builds |
listening | Listening practice (hear it, type it), Sprint mode listening exercises (tap / fill in what you hear, what does it mean) |
guide | Hand-ordered Guide steps |
sprint | Sprint 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, order | Lesson 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." } ]
}