Authoring Quizzes

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.

1. Multiple choice

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:

2. Free text

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:

3. Randomised questions

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.

// 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):

4. Figures as the choices

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:

5. A diagram above the question

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:

6. Algebraic answers

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:

7. An interactive problem as a question

A bank item can be a whole interactive sandbox that generates and grades itself. { problem: "name" } drops in a geometry angle-chase (a registered 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:

8. Write a program

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 runnable code blocks, then grade 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.