A <primer-quiz name="…"> builds a short test from a bank of questions
you write in JavaScript with registerQuiz(name, builder) — the same pattern
as registerManimScene / registerChart. The element references the
bank by name; the builder receives a toolkit { sceneStrings } and
returns the bank. Each question is one of three kinds.
Settings. The builder's optional first item is a config object
{ num_questions, preamble } — recognised by having no options or
answer. num_questions sets how many questions to draw at random
(default 5); preamble is an instructions sentence shown in normal
font under the heading (route it through sceneStrings to translate it). Because
these live in the language-neutral builder, the count is shared across every locale — there's
no count attribute to keep in sync between a page and its overlay.
Translation, for free. Translatable prose comes from a
scene-strings block (keyed by the quiz name) via
sceneStrings("key"); language-neutral maths stays as an inline literal. A
translation overlay supplies only the translated strings — never the bank — so an all-maths
quiz needs no translation at all. Version the name (@1) and bump it on an
incompatible change.
Give a question options. Exactly one (or more) is marked
correct: true; the options are shuffled for each learner. Prompt and option
text may contain inline maths between $…$. Route prose through
sceneStrings; leave maths inline.
// scene-strings: { "mcDemo@1": { "intro": "Pick the sum that works.", "tenQ": "Which sum equals $10$?" } }
registerQuiz("mcDemo@1", ({ sceneStrings }) => [
{ num_questions: 2, preamble: sceneStrings("intro") }, // ← config: no options/answer
{ prompt: () => sceneStrings("tenQ"),
options: [
{ text: "$7 + 3$", correct: true }, // maths → inline
{ text: "$6 + 3$", correct: false },
{ text: "$5 + 4$", correct: false },
] },
]);
Live:
Give a question an answer instead of options and the learner types
into a box. The answer can be a literal (a number, or text), and it may be a
function — handy for a localizable text answer. Typed answers are graded
numerically with a small tolerance, or as case- and space-insensitive text.
// scene-strings: { "textDemo@1": { "capitalQ": "What is the capital of France?", "capitalA": "Paris" } }
registerQuiz("textDemo@1", ({ sceneStrings }) => [
{ prompt: () => sceneStrings("capitalQ"),
answer: () => sceneStrings("capitalA") }, // localizable text answer
]);
Live:
Add variables and the question becomes a template: fresh random values
each draw, passed to your prompt/text/answer
functions as the bindings object v. A single template can fill several slots,
so each learner gets a different set.
a=[1:10] — an integer in 1…10.b=[1;10] — a real number (3 dp) in 1…10.c=[2,3,5] — a choice of one listed value.answer: "a * b" — its
{}/expression is evaluated against the draw) or a function
(answer: (v) => v.a * v.b); the two are identical. Option text
works the same way ("${a + b}$" ≡ (v) => `$${v.a + v.b}$`).
Interpolate values into a localized prompt via sceneStrings("key", v) (a
{a} placeholder fills with the value).// scene-strings: { "randomDemo@1": { "timesQ": "What is ${a} \\times {b}$?" } }
registerQuiz("randomDemo@1", ({ sceneStrings }) => [
{ prompt: (v) => sceneStrings("timesQ", v),
variables: "a=[2:9] b=[2:9]",
answer: "a * b" }, // string expression ≡ (v) => v.a * v.b
]);
Live (reload the page for new numbers):
An option can be a picture instead of text. Give it a chart (a
registered chart scene) or a geometry (a registered geometry scene) in place of
text, and the choices render as a 2-column grid of small figures. Figure options
carry no words, so they need no translation.
registerQuiz("figureDemo@1", ({ sceneStrings }) => [
{ prompt: () => sceneStrings("whichParabola"),
options: [
{ chart: "optParabola", correct: true }, // ← a registered <primer-chart> scene
{ chart: "optLine", correct: false },
{ chart: "optCubic", correct: false },
] },
{ prompt: () => sceneStrings("whichRight"),
options: [
{ geometry: "optRightTri", correct: true }, // ← a registered <primer-geometry> scene
{ geometry: "optAcuteTri", correct: false },
] },
]);
Live:
Any question (multiple-choice or free-text) may show a figure — a registered
geometry scene rendered above the prompt — for a "given this diagram, work it out"
question. Pair a free-text angle answer with keyboard: "geometry-angles" so the
learner gets a degree-friendly math keyboard; a numeric answer accepts 70,
70° or 70 degrees.
registerQuiz("diagramDemo@1", ({ sceneStrings }) => [
{ figure: "triGiven", // ← a registered <primer-geometry> scene, drawn above
prompt: () => sceneStrings("thirdAngle"),
answer: 70,
keyboard: "geometry-angles" },
]);
Live:
Add compare: "polynomial" and the answer box becomes a math editor graded by
algebraic equivalence (via the CortexJS Compute Engine, loaded on demand) —
so any equivalent form is accepted: (x+2)(x+3), x^2+5x+6, a
reordering, a factorisation. Type ^ for an exponent.
registerQuiz("polyDemo@1", ({ sceneStrings }) => [
{ prompt: () => `${sceneStrings("expand")} $(x+2)(x+3)$`,
answer: "x^2 + 5x + 6",
compare: "polynomial" },
]);
Live:
A bank item can be a whole interactive sandbox that generates and grades
itself. { problem: "name" } drops in a
registerGeometryProblem); the quiz's "Check answers" triggers its
own check and folds solved / not-solved into the score. It carries no
options/answer, so it's never mistaken for the config item.
registerQuiz("problemDemo@1", () => [
{ problem: "quizChase" }, // ← a registered geometry problem, embedded and self-grading
]);
Live:
The newest kind asks the learner to write code. Register the exercise with
registerProgram(name, config); each attempt hands the learner a fresh random
value in the global INPUT (a number, string, array, object — whatever your
input builds), and their job is to assign the result to the global
ANSWER. We transpile the TypeScript and run it in the same sandboxed engine as
ANSWER against your reference solution (numbers with a tolerance;
arrays and objects structurally).
registerProgram("sumList", {
prompt: "Add up all the numbers in the list INPUT, and store the total in ANSWER.",
variables: "n=[3:6]", // drawn each attempt
input: (b, rng) => Array.from({ length: b.n }, () => rng.int(1, 9)), // → the INPUT value
solution: (INPUT) => INPUT.reduce((a, c) => a + c, 0), // → the correct ANSWER
starter: "let total = 0;\nfor (const x of INPUT) total += x;\nANSWER = total;",
});
Used on its own it's a <primer-program> element with its own Run
(see your output and what ANSWER came out as), Check,
New input and Reset code buttons. Try it — edit the code,
Run it, then Check; press New input for a fresh list:
To drop the same exercise into a quiz, reference it with { program: "name" } —
it renders inline (its own New-input / Check hidden, since the quiz's "Check answers" drives
it) and folds correct / incorrect into the score, just like the geometry problem above:
registerQuiz("programDemo@1", () => [
{ program: "squareIt" }, // ← a registered program exercise, embedded and self-grading
]);
Live:
That's the whole quiz vocabulary — six question kinds: multiple choice (with text or figure
options), free text (plain, randomised, or polynomial), a figure above the prompt, an
embedded interactive problem, and a write-a-program exercise. Mix them freely in one bank:
the Primer draws num_questions of them and grades whatever the learner answers.