Loop of the Recursive Dragon
A free, browser-based quiz-RPG for learning programming, networking, and computer science. Answer questions to battle monsters, level up your character, and track your progress across topic areas — built originally for college students, but open to anyone studying for an exam like Network+ or working through a first Java course.
A game by Brendan Shea, PhD — [email protected]
No account, no install, no tracking. Progress saves locally in your browser via localStorage. Pick a topic, start a set, and your spot is remembered next time you visit.
How to Play
- Open
index.html in a browser (or serve the folder with any static file server).
- From the main menu, choose a topic and question set.
- Each encounter presents a question. Answer correctly to damage the monster; wrong answers let the monster hit back.
- Defeat monsters to earn XP and level up. Each level grants a revive charge (⚗️) — when your HP hits 0, a charge is consumed to restore 10 HP. With none left, you go back to the last checkpoint and redo the questions since then.
- Your player level persists globally across all question sets. Finishing a set by defeating the last monster or exhausting all remaining questions counts as clearing it. Progress auto-saves to
localStorage.
Keyboard Shortcuts
| Key | Action |
|---|
1–9 | Toggle answer checkboxes (multiple-choice) |
Enter | Submit answer / advance to next screen |
Enter (in text field) | Submit fill-in-the-blank answer |
Features
- Ten question formats plus NPC teaching scenes (described below)
- Main menu with topic groupings, per-set progress badges, and a global stats bar
- Lesson intros — sets can declare an
intro (story + learning objectives) in catalog.json, shown before the run starts
- Sequential first pass — full runs present questions in authored order (the set is written as a progression, and NPC scenes precede their paired questions); missed questions still requeue, and reviews/trials shuffle their samples
- Mastery tiers — the first full clear earns Apprentice rank (80% of the set's gradebook credit). A Journeyman trial unlocks 3 days later (90%), and a Master trial 7 days after completing Journeyman (100%). Trials are half the set (capped at 18 questions), weighted toward historically missed questions; finishing the run (retrieval boss included) earns the rank. Credit only ever rises. Sets cleared before tiers existed are grandfathered at Master
- Auto-save — every encounter result is saved to
localStorage; the back button saves and returns to the menu
- Completion tracking —
lotrd_done_${setId} records cleared sets permanently, including runs that end because there are no questions left
- Global stats —
lotrd_global accumulates lifetime totals across all sets
- Session review — question-by-question pass/fail breakdown with export to
.txt
- Direct-link support — instructors can share
?set=filename.json URLs
- Battle stage — your hero (chosen on the main menu, from six) faces the monster, each over its own HP bar. The fight plays out instead of being described: the hero lunges and the monster reels, the monster counters, a defeated monster flashes and vanishes, a knocked-out hero falls (and gets back up on a revive), and items show their effect — a heal glows, an armed Firewall Shard is a bubble that bursts when it blocks, a Stack Mirror sends the blow back, a Logic Bomb is thrown. A click or keypress skips to the results; nothing moves under reduced motion. An answer right the first time gets no results screen: a short note over the fight says what it did, and the next question follows by itself (a click or key moves on sooner). Anything with a mistake in it, and every write-the-code answer, still gets the full screen with its explanation. Sprites are three-pose strips (stand, attack, recoil) in
images/sprites/, cut from generated sheets — see Sprites
Question Types
All question types live in the same JSON arrays and can be mixed within one set. Existing questions with no type field are treated as multiple-choice (default). Dynamic numeric questions use a safe expression engine with curated helper functions; JSON content does not execute arbitrary JavaScript.
Multiple Choice (default)
The original type. Select all correct answers; partial credit is not given for individual selections — the damage formula counts each correct selection and each wrong one separately.
{
"question": "Which are valid Java variable declarations?",
"correct": ["int x = 5;", "String name = \"Alice\";"],
"incorrect": ["int x = 5", "variable x = 5;"],
"feedback": "Optional explanation shown on the results screen."
}
Fill-in-the-Blank
{
"type": "fill_blank",
"question": "The keyword used to inherit from a class in Java is ___.",
"correct": ["extends"],
"case_sensitive": true,
"feedback": "Optional explanation."
}
correct is an array — list all acceptable answers (e.g. ["true", "True", "TRUE"]).
case_sensitive: true should be used for Java keywords and code; omit or set false for prose answers.
- The UI shows a per-word character-count hint (e.g.
_ _ _ _ _ _ _ (7 chars)).
- Players get up to 3 attempts. Wrong attempts show Wordle-style feedback; long answers may auto-cloze so only one word needs to be typed.
- A one-character typo on a long non-case-sensitive answer is accepted automatically. Not for
code_trace, where one character is usually the thing being traced.
- Missed questions re-queue, and final failure keeps partial credit based on similarity.
Dynamic Numeric
{
"type": "dynamic_numeric",
"question": "What decimal value does binary {{bits}} represent?",
"variables": {
"n": { "min": 8, "max": 63 }
},
"derived": {
"bits": "toBin(n)"
},
"answer": {
"expr": "fromBase(bits, 2)",
"tolerance_abs": 0
},
"feedback_template": "Binary {{bits}} equals {{expected}} in decimal."
}
variables define numeric inputs using either min / max / optional step or an explicit values list.
derived lets authors compute helper values for the rendered prompt, such as binary or hex strings.
question is a template string that can reference resolved values with {{name}} placeholders.
answer.expr must evaluate to a single finite number. tolerance_abs controls accepted error.
- Supported helper functions include
toBin, toHex, fromBase, bitAnd, bitOr, bitXor, shl, shr, popcount, bitLength, sumRange, countRange, plus arithmetic helpers like min, max, abs, pow, floor, ceil, and round.
- Dynamic numeric questions reuse the 3-attempt fill-blank flow, but numeric misses show high/low feedback and accepted tolerance instead of character hints.
Code Trace ("predict the output")
{
"type": "code_trace",
"question": "What does this loop print?",
"code": "for (int i = 1; i <= 3; i++) {\n System.out.println(\"Hi \" + i);\n}",
"language": "java",
"correct": ["Hi 1\nHi 2\nHi 3"],
"case_sensitive": true,
"feedback": "Optional explanation."
}
- The snippet is shown in a monospaced
<pre> block. When language is "java" (default), keywords, strings, numbers, and comments are syntax-highlighted by src/highlight.js; other languages render plain.
- Player types the program's expected stdout into a multi-line
<textarea>, one value per line. Submit with Ctrl/Cmd+Enter (plain Enter inserts a newline).
- Input is normalised before comparison: line endings unified, trailing whitespace per line stripped, leading/trailing blank lines dropped — write
correct answers exactly as printed, with \n between lines.
- Scoring reuses the fill-blank engine: 3 attempts, Levenshtein-based partial credit on the final miss, monster counter-attack on each wrong attempt. The wordle-style per-character grid is hidden for code-trace.
Code Line
{
"type": "code_line",
"question": "Declare an empty ArrayList of Strings named names.",
"language": "java",
"correct": [
"List<String> names = new ArrayList<>();",
"ArrayList<String> names = new ArrayList<>();"
],
"case_sensitive": true,
"feedback": "Declaring as List<String> is preferred — it keeps the left-hand side flexible."
}
- Best for one-line code or command authoring.
- Grading is token-based, so whitespace-only differences do not matter.
- Players get 3 attempts and token-Wordle feedback. A "did you mean?" prompt, which shows the answer without using an attempt, appears only for a slip: the same tokens except one misspelled word of 4+ letters (one keystroke off) and/or a missing trailing
;. A different operator, number, string or short name is graded as an answer.
Matching
{
"type": "matching",
"question": "Match each OOP term to its definition.",
"pairs": [
{ "term": "Encapsulation", "definition": "Bundling data and methods, restricting direct access" },
{ "term": "Polymorphism", "definition": "One interface can represent many types at runtime" }
],
"feedback": "Optional explanation."
}
- Recommended size: 4–6 pairs.
- Definitions are shuffled into per-row dropdowns.
- Scoring is proportional — each correct pair rolls one attack die (d6); each wrong pair rolls one monster die. The question re-queues if any pair is wrong.
Ordering
{
"type": "ordering",
"question": "Arrange one full turn of the instruction cycle in order.",
"items": [
"Fetch the next instruction from memory",
"Decode the instruction to see what it asks",
"Execute the operation"
],
"feedback": "Optional explanation."
}
items is written in the correct order (3–6 unique strings); the game shuffles the bank and the student taps items into sequence (tap a placed item to remove it).
- Optional
language renders items in monospace — use it for Parsons-style "arrange the code lines" questions.
- Scoring is positional — each item in its correct slot rolls one attack die; each misplaced item rolls one monster die. Anything less than perfect re-queues.
Multi-Blank Cloze
{
"type": "cloze",
"question": "The {{1}} layer routes packets, while the {{2}} layer makes delivery reliable.",
"blanks": [
{ "accept": ["Internet", "IP"], "hint": "layer" },
{ "accept": ["transport"] }
],
"feedback": "Optional explanation."
}
- The prompt renders as flowing text with an inline input wherever a
{{n}} placeholder sits (1-based, and every blank needs one).
- 2–4 blanks, each typable in ≤ 12 characters; each carries its own
accept list, optional hint placeholder text, and optional case_sensitive.
- Graded per blank in one submission — no three-attempt loop. Each correct blank rolls an attack die, each wrong one rolls a monster die, and the question re-queues unless all are right. A one-character typo on a long case-insensitive answer is still forgiven.
Enter advances to the next blank and submits from the last one.
Write the Code (code_write)
A CodingBat-style problem: the signature is fixed and shown above the box, the student writes the body, and the code is run against a table of test cases.
{
"type": "code_write",
"question": "Return the sum of two numbers — except if they are equal, return double their sum.",
"signature": "def sum_double(a, b):",
"tests": [
{ "args": [1, 2], "expect": 3 },
{ "args": [2, 2], "expect": 8 }
],
"solution": "if a == b:\n return (a + b) * 2\nreturn a + b",
"hint": "optional one-line nudge, behind a Stuck? fold",
"starter": "optional prefilled body",
"examples": ["optional; the first three tests are used when this is absent"],
"feedback": "Optional explanation."
}
- Where they appear. The standalone Python Lab set is all
code_write; Computing Concepts chapters 3, 4 and 5 each end with six of them, after the reading and tracing questions that teach the same construct and after a mentor scene introducing the format. Chapter 3's are pure expressions (no if, no loops), chapter 4's use conditionals and loops, chapter 5's use lists and dictionaries — matching what each chapter has taught. Most are ported from the course's own PyQuiz banks, so a student meets the same problems the notebook set them.
hint is optional: one line, kept behind a Stuck? Show a hint fold so opening it is the student's decision.
- Run is free and unlimited. The Run button executes the same tests that will grade the answer and shows expected against actual, row by row. Only Submit resolves the turn.
- Graded per test case, like matching and cloze, but the attack dice are scaled to a fixed budget (
CODE_WRITE_HIT_BUDGET) rather than one die per case — otherwise a ten-case problem would deal twice the damage of a five-case one for the same work. The question re-queues unless every case passes.
- A worked answer is shown afterwards, whether or not the student's own passed.
solution is required, and the test suite runs it through the real interpreter: a problem whose own answer key fails is a failing build. The suite also rejects a test table that return None / return True / return 0 and friends can pass, since such a table grades nothing.
args and expect are ordinary JSON. Whole numbers become Python ints and fractions become floats; {"__float": 5} forces 5.0 and {"__tuple": [1, 2]} makes a tuple. Comparison uses Python's ==, so an int answer still matches a float expectation.
- The body may be typed flush against the left margin or already indented — whichever the student does, the shallowest line becomes one level of indentation. Tab inserts four spaces, Shift+Tab removes them, Enter keeps the current indent and adds a level after a colon.
Ctrl+Enter runs the tests. On-screen Indent/Outdent buttons do the same job on a phone, whose keyboard has no Tab key; on phones they share a row with Run that pins to the bottom of the screen, while Submit stays down in the flow so the irreversible action always costs a deliberate scroll.
- The editor shows line numbers and highlights keywords. Errors are reported against the lines the student typed (the fixed signature is not line 1), so the gutter number and the "line 3" in a message are the same line — and after a failed run that line is marked in the gutter. The highlighting is a painted behind a transparent-text textarea; the three layers only line up while their font, line height, padding and tab size are identical, which enforces against the stylesheet.
The code runs on src/pytiny.js, a small Python interpreter written for this purpose (see Runtime Architecture).
A question marked "language": "java" is a Java method instead, run by src/jtiny.js: the signature is the method line, the student writes the body, and the same test table grades it. See Java problems in the writing guide for what it runs and what it declines. The standalone Java Lab set (java_lab_01_methods.json) is thirty of them, from true-and-false tests through Strings and loops to arrays. From the Objects and Classes chapter on, a Java question may instead ask for one method of a class that is given, or for a whole class (a subclass or a class that implements interfaces, from the Inheritance chapter on); those are graded, like the Python class problems, by short scripts that make objects and then look at one thing (Java class problems in the writing guide).
Write the Query (sql_write)
The SQL counterpart of write-the-code. The student writes a whole SELECT, and it is run — by SQLite itself, compiled to WebAssembly — against tables shown on the screen.
{
"type": "sql_write",
"question": "List the name of every candy made by Gumley.",
"database": "candy_factory",
"tables": ["candies"],
"solution": "SELECT name FROM candies WHERE maker = 'Gumley';"
}
- Graded by result, on rows the student cannot see. A database file (
question_sets/databases/) holds the tables and several datasets. The first is shown; the query must return the same rows as the reference query on all of them, so a query that just spells out the visible answer fails.
- Real SQLite, not an imitation. The engine is sql.js (MIT), vendored in
vendor/sqljs/. What a query returns here is what it returns in the course's own notebooks.
- It runs in a background worker, on a clock. A query that would never finish — tables joined with no condition — is stopped after a few seconds by terminating the worker, and the page stays responsive throughout.
- Errors are put into sentences. SQLite's
near "candies": syntax error becomes "FORM looks like a misspelling of FROM"; a wrong column name is answered with the columns that exist.
- The engine (about 340 KB zipped) is loaded only when a set has one of these problems, and shipped only in SCORM packages that do.
NPC Teaching Scene (npc_demo)
Not a question: a mentor NPC (default: Ada the Artificer) walks through a worked example step by step, with optional low-stakes check prompts ("what comes next?") that get a gentle correction when wrong. Scenes deal no damage, award no XP, never requeue, are skippable, don't count toward question_count, and are excluded from reviews and rank trials. Author each scene immediately before a paired question that uses the same technique with different surface features. See the WRITING_GUIDE for the schema.
Damage Formula
| Situation | Player damage | Monster damage |
|---|
| Multiple-choice: each correct selection | +1 attack roll (d6) | — |
| Multiple-choice: each distractor left unselected | +1 attack roll (d6) — on a single-answer question, only when the answer is right | — |
| Multiple-choice: each wrong / missed selection | — | +1 monster roll (a wrong pick on a single-answer question is one mistake, not two) |
| Fill-blank / dynamic numeric / code-trace: correct within 3 tries | 1 attack roll (d6) × streak multiplier | — |
| Fill-blank / dynamic numeric / code-trace: wrong intermediate guess | — | 1 monster roll, scaled by attempt (first miss free, second 50%) |
| Code-line: correct within 3 tries | 1 attack roll (d6) × streak multiplier | — |
| Code-line: wrong intermediate guess | — | 1 monster roll |
| Any typed answer: out of tries, or given up | — | 1 monster roll |
| Matching / ordering / cloze: each correct pair, position or blank | 1 attack roll (d6) × streak multiplier | — |
| Matching / ordering / cloze: each wrong one | — | 1 monster roll |
However wrong one answer is, the monster rolls at most 2 dice for it (MAX_MONSTER_DICE), so no single answer can take a fresh 20-HP hero to zero. Beating a monster restores 25% of max HP (KILL_HEAL_SHARE), not after the dragon.
Final failure on fill-blank, dynamic numeric, code-trace, and code-line keeps partial credit based on best similarity or numeric closeness and re-queues the question. Streak multipliers (consecutive perfect answers): 3–4 = 1.25×, 5–9 = 1.5×, 10+ = 2×. Monster defense reduces player damage and player base defense reduces monster damage, but a blow that was earned always lands for at least 1 (landed): a wrong answer always draws a counterattack, and a partly right one shows both fighters striking. Dice never hurt the dragon. The hero's attack die is a fixed d6; max HP starts at 20 and base defense at 1. Each level-up grants a revive charge and +2 max HP, plus +1 base defense at every 5th level.
Project Structure
index.html — All HTML templates (SPA; templates are <template> elements)
styles.css — BBS-style dark-green terminal theme
src/
main.js — Entry point; instantiates GameController
app.js — Barrel module that re-exports the main runtime classes
controller.js — GameController; game flow, persistence, item drops, review-set launch
ui.js — GameUI; all screen rendering, keyboard handling, encounter/results screens
sound.js — SoundSystem and the SFX table; woken by every press, kept awake by an inaudible hum, never played late
zzfx.js — ZzFX's sample generator (MIT), which turns each SFX entry into a sound
items.js — ITEM_DROPS table for post-battle loot
stage.js — The battle stage's choreography: a round or an item as timed beats
heroes.js — The heroes a student can play as, and which one this browser chose
model.js — Player, Monster, GameModel; game logic, battle math, checkpoints, save/load state
pytiny.js — A small Python interpreter; runs student code for code_write questions
jtiny.js — A small Java, type-checked the way javac does; runs Java code_write questions
sqlgrade.js — Grades a student's query against a reference query, on every dataset
sqlengine.js — Runs SQLite (vendor/sqljs) in a background worker, with a time limit
highlight.js — Minimal Java and Python syntax highlighters
util.js — Shared helpers such as shuffle()
assets/
monsters.json — Monster definitions (name, hit_dice, attack_die, defense, image, sprite)
questions.json — Legacy; not used by the main app
images/monsters/ — Monster artwork (single pictures; the battle stage uses images/sprites/)
images/sprites/ — Battle-stage sprite strips, plus roster.json and prompts.md that make them
question_sets/
catalog.json — Topic groupings and metadata for the main menu
index.json — Flat list of available set filenames (for direct-link validation)
*.json — Individual question sets
tests/
model.test.js — Unit tests for game logic (Player, Monster, GameModel)
data.test.js — Validates all JSON data files (monsters, question sets, catalog)
html.test.js — Cross-reference checks (templates, data-refs, stale code)
pytiny.test.js — The Python interpreter, checked against real CPython output
jtiny.test.js — The practice Java: what a student sees (results, error wording, line numbers)
jtiny-differential.test.js — The practice Java against the real JDK (needs JDK 25)
finishable.test.js— Every shipped set can be played to a terminal screen
Runtime Architecture
main.js is the browser entrypoint and creates a single GameController instance.
controller.js owns application state transitions: loading sets, saving progress, resuming games, resolving battles, and routing to the right screen.
ui.js is intentionally presentation-focused: it renders templates, handles keyboard shortcuts, and calls controller methods instead of reaching through globals.
model.js owns the combat and progression rules: player/monster state, streaks, dynamic question materialization, safe expression evaluation, answer grading, shared damage resolution, and save snapshots.
pytiny.js is a self-contained Python interpreter — tokenizer, parser, tree-walking evaluator — with no dependencies and no reach into the page. It exists because code_write questions have to run student code, and the alternative (Pyodide) is several megabytes of WebAssembly that the offline SCORM packages could not carry. It runs the Python of a first course and refuses everything else by name; errors are phrased in the words of that course ("you tried to add text to a number"), and every run is bounded by step, time, recursion, integer-size and output limits so a runaway loop reports itself instead of freezing the tab. Student text is never passed to eval or Function. Its test suite compares it against output captured from real CPython.
sound.js is isolated from UI and model logic, so audio concerns stay separate from gameplay and rendering.
items.js and util.js hold small shared data/helpers that were previously duplicated inline.
Adding New Question Sets
- Create
question_sets/your_set.json — a JSON array of question objects (any mix of types).
- Add the filename to
question_sets/index.json.
- Add an entry to the appropriate topic in
question_sets/catalog.json.
Available Question Sets
question_sets/catalog.json is the source of truth for what the
main menu shows. The counts below mirror it, and data.test.js asserts that the catalog itself
matches the number of questions actually in each file.
| Topic | Set | Questions |
|---|
| Computing Concepts | What Is Computing? | 50 |
| Computing Concepts | Machine Architecture | 50 |
| Computing Concepts | Python Basics | 50 |
| Computing Concepts | Control Flow & Functions | 50 |
| Computing Concepts | Collections & ADTs | 50 |
| Computing Concepts | Modules & Objects | 50 |
| Computing Concepts | Searching, Sorting & Big-O | 50 |
| Computing Concepts | Software Engineering | 50 |
| Computing Concepts | Databases & the Relational Model | 50 |
| Computing Concepts | OS, Networks, Cloud & the Web | 50 |
| Computing Concepts | Cybersecurity — Defending Systems | 50 |
| Computing Concepts | AI, Machine Learning & Ethics | 50 |
| Database | Database Foundations | 33 |
| Database | Introduction to SQL: SELECT | 30 |
| Database | Joins and Set Operations | 30 |
| Database | Super Select: Advanced Retrieval | 30 |
| Database | Database Design | 30 |
| Database | Writing Data | 30 |
| Database | Views, CTEs & Governance | 30 |
| Database | Performance & Transactions | 30 |
| Database | PostgreSQL Basics | 30 |
| Database | Database Security | 30 |
| Database | Architecture, Deployment & Testing | 30 |
| Database | SQLite Final Project | 30 |
| Java | Java Review Mix | 30 (random mix) |
| Java | Hour of Java | 15 |
| Java | Java Basics |
localStorage Keys
| Key | Contents |
|---|
lotrd_save_${setId} | In-progress game state for a set |
lotrd_pos_${setId} | The same run reduced to question numbers (remaining, missed, tally, set fingerprint) — small enough for the SCORM shim to carry between devices |
lotrd_done_${setId} | Completion record (timestamp, score %, level) |
lotrd_tier_${setId} | Mastery-tier record (tier, apprenticeAt/journeymanAt/masterAt timestamps) |
lotrd_misses_${setId} | Historical miss counts per question text (weights rank-trial samples) |
lotrd_review_${setId} | Spaced-review stage and last-reviewed timestamp |
lotrd_global | Lifetime totals: answered, correct, incorrect, best streak, sets completed |
lotrd_player_level | Global player level data (level, XP, revive charges) |
lotrd_sound | Sound preference: "1" on, "0" off |
lotrd_hero | The hero this browser plays as (an id from src/heroes.js) |
Sounds
Every sound effect is a short list of numbers in SFX (src/sound.js), turned into audio by ZzFX (MIT): no audio files, nothing to license, and nothing extra in a package. The fight's sounds play on the stage beats that show them (sfxFor in src/stage.js): the swing as the hero lunges, the hit as the blow lands, the monster's counter, a kill, an item's glow, a drop. They still play under reduced motion, which stills the stage but not the sound. Outside the fight, every button press makes a quiet click (pressSound in src/ui.js), except an answer or an item, which the fight answers instead; a button can name its own sound with data-sound, which is how a mentor's Continue blips and a mentor's check chimes right or not quite.
To hear or change them, serve the repo root and open /tools/sound-board.html. It plays each sound alone or a whole round with the game's own timing, takes edited numbers or anything pasted from the ZzFX designer, and writes out a new SFX table to paste into src/sound.js. The board is not shipped in the SCORM packages.
Sprites
Every hero and monster on the battle stage is a strip of three poses (stand, attack, recoil) in images/sprites/<id>.webp, all facing right; the stage mirrors the monster. They are made in batches of three characters:
images/sprites/roster.json describes each character, three to a sheet. python tools/sprite_prompts.py writes images/sprites/prompts.md from it: one prompt per sheet, ready to paste into an image generator, with a table of which sheets are done.
- Save each generated image as
images/sprites/sheets/<sheet>.png (or .jpg/.webp). The sheets are source material and stay out of git and out of SCORM packages.
python tools/sprite_sheets.py [<sheet> ...] keys out the magenta background, cuts each row into a strip, and points that monster's entry in assets/monsters.json at it with "sprite". It needs Pillow, NumPy, and SciPy.
A monster without a "sprite" still works: the stage shows its old picture, framed. Heroes are listed in src/heroes.js.
Releasing Updated Content
- Question-set JSON files are fetched with
cache: "no-store", so browsers should re-request fresh content instead of reusing a stale cached copy.
- In-progress runs are restored from
localStorage. If you change question content and want existing browsers to discard old in-progress snapshots, bump SAVE_DATA_VERSION in src/controller.js.
- Global progress and completed-set records are preserved; only
lotrd_save_* keys are invalidated by a save-data version bump.
Running Tests
Requires Node.js 22+ (uses the built-in node:test runner — zero npm dependencies).
The whole suite takes about ten seconds.
node --test "tests/*.test.js"
The rule the suite exists to keep
Students never lose work they have done — above all, a problem set they
have completed.
This has regressed more than once, each time because it depends on several
parts agreeing (the game's saves, the SCORM shim, the LMS, the browser's
storage) and nothing tested them together. It is now enforced at three layers:
| Layer | File | What it does |
|---|
| Scenarios | dataloss.test.js | The real game and the real shim against one fake LMS, as things that happen to students: switching browsers, D2L logging them out mid-set, the tab dying, Safari emptying its storage, a stale laptop catching up, two devices open at once, a republished package. Plus a seeded random walk over all of those at once. |
| Real browser | browser.test.js | The actual page in headless Chrome/Edge, framed by a stand-in for D2L whose record outlives the browser — so two profiles are two devices. Skipped (loudly) if no browser is installed; CI requires one. |
| Parts | scorm.test.js, position.test.js | The shim alone; the portable-position encoding and resume rules. |
It cannot be bypassed by forgetting to run it:
SCORM/build.py runs the suite first and packages nothing if it is red.
A package is what reaches students.
- GitHub Actions runs it on every push (
.github/workflows/tests.yml).
- A pre-push hook runs it before anything leaves this machine. Enable it once
per clone:
git config core.hooksPath .githooks
If you change anything about saving, syncing or restoring, add the scenario to
dataloss.test.js first. Then prove the test can fail:
python tests/tools/prove_dataloss_tests.py
That re-introduces each bug that has actually cost students work, one at a time,
and reports whether the suite CAUGHT or MISSED it. A new data-loss fix should
come with a new entry there.
The one loss that cannot be prevented — work done while the LMS was unreachable,
in a browser whose storage is then destroyed before the student reopens the
activity there — is written down as a test as well, so that it stays the only one.
| File | What it checks |
|---|
model.test.js | Model and combat logic — player state, streaks, fill-blank, dynamic numeric, code-line, matching, level-up, revive, items, retrieval boss, save round-trips |
controller.test.js | Controller logic without the DOM — Mulligan rollback, item activation |
dataloss.test.js | Students never lose work they have done — see above |
browser.test.js | The same guarantee in a real browser |
scorm.test.js | The SCORM shim alone — grade preservation, confirmed writes, reconnection, page lifecycle |
position.test.js | Carrying a half-finished set between devices — encoding, rebuilding a run, which save wins |
data.test.js | All JSON files — required fields, type constraints, dynamic-question schema, image files exist, catalog/index consistency, question-count and duplicate-text checks |
html.test.js | HTML/template cross-checks — template IDs, data-ref/data-action usage, stale code checks, accessibility, CSS classes |
util.test.js | Shuffle and the spaced-review interval schedule |
pytiny.test.js | The Python subset used by write-the-code questions — parsing, evaluation, the runaway-program limits, and the wording of every student-facing error |
jtiny.test.js | The Java subset used by Java write-the-code questions — the results table, error wording and line numbers, pasted methods and classes, and the runaway-program limits |
jtiny-differential.test.js | The same Java against javac and the JVM: hand-written cases, programs that must not compile, and generated arithmetic, method bodies and type puzzles. Skipped, loudly, without a JDK 25 (LOTRD_JAVA names one); CI requires it |
ui.test.js | UI logic reachable without a DOM — code-editor indentation (the only route on a phone) and the editor's markup |
finishable.test.js | Every set can be finished — no question can lock a run |
scorm.test.js | The SCORM wrapper — completion, scoring and suspend data |
License
See LICENSE.