Skip to main content
  1. Posts/

Verba — An Offline Latin Vocabulary Trainer on a USB Stick

· David Steeman · AI, DIY
Verba — An Offline Latin Vocabulary Trainer on a USB Stick

My son has to know 1051 Latin words. They come from the vocabulary list in the back of his school book, spread over seven caputs and 35 sections, and they arrive at the rate of a section or two per week. Flashcard apps exist, of course, but every one of them wants an account, a subscription, a phone in his hand and a network connection — three things I did not want in the same room as his homework.

So we built our own. It is called Verba, it is a single HTML file, and it lives on a USB stick. You double-click it and it works. No server, no installation, no internet, and — by design — not a single network request.

The Verba home screen

Why a single file
#

The constraint came first and everything else followed from it. One file that runs over file:// means no fetch(), no XMLHttpRequest, no service worker, no ES modules, no dynamic import(), no CDN, no web fonts. Vanilla HTML, CSS and JavaScript, icons as inline SVG, the system font stack. The finished app is about 410 KB, of which roughly 330 KB is the word data sitting inline in a <script> block.

That sounds like a limitation. In practice it is the feature. It cannot break because a server is down, it cannot nag, it cannot be blocked by school wifi, and it will still open in ten years. Progress lives in localStorage with a backup save/load to a JSON file, because localStorage over file:// works in Chrome and Firefox but is not something you want to bet a term’s revision on.

This is the second app in this shape. The first was FLUO, the same idea for the periodic table. Verba deliberately reuses its architecture, its learning engine and its gamification — if you know one, you know the other.

The word list is the hard part
#

Everything interesting about a vocabulary trainer is in the data, not the UI. A trainer with a wrong word list is worse than no trainer at all: it teaches the mistake, confidently, with a little green flash.

From the flatbed to markdown
#

There was no digital version of the list to start from, so I put the back of the book on the office scanner. That scanner does one thing with what it scans: it emails it. Out came a pile of PDF attachments spread over a series of messages, in scan order, which is not quite page order. I saved those emails as .msg files into a shared folder and pointed Claude Code at the folder.

From there it was one job: open the .msg files, pull the PDFs out of them, read the vocabulary pages, and write out every numbered entry as a markdown table — number, Latin word, genitive or second form, Dutch translation — grouped by caput and section, carrying the book’s own conventions along (~a, ~um for adjective endings, m./v./o. for gender, - for a form the book does not give). Marginal cross-references and the highlighter marks my son had left on the pages were explicitly ignored.

The output is woordenlijst.md: 1051 numbered words in plain markdown tables, readable by a human, with the provenance and the conventions written at the top of the file.

Cross-checking against the index
#

Transcribing a thousand entries from a scan produces errors. The book, usefully, contains its own checksum: an alphabetical index at the back, pages 70–84, with 1044 number references back into the vocabulary list. That is a second, independently printed copy of the same data.

So the second job was to scan that index too and check the whole list against it, mechanically: every number from 1 to 1051 present and unbroken, and every index entry pointing at the word it claims to point at.

The list came out clean. The index did not. Six entries in it are wrong, and in all six cases the vocabulary list itself is correct:

Index saysShould be
cōnfīdere → 913213
hīc → 346347
quod → 378376
regiō → 876878
laetus → 960961
continuere (1012)contingere

Five wrong number references and one misspelled lemma, in a printed school book that has presumably been through several editions. Finding them was a side effect of wanting to trust the data — but it is a good illustration of why the cross-check was worth doing at all. Without it I would have had a list that looked right and no way to know.

That header, including the six discrepancies, is written into the top of woordenlijst.md, so the reasoning stays with the data.

One source, two scripts
#

From woordenlijst.md on, everything is generated. A Python script turns it into latijn.json. A second script injects that JSON into an HTML template to produce verba/index.html. Three inputs, one artifact:

woordenlijst.md ──(maak-data.py)──▶ latijn.json ──(bouw.py)──▶ verba/index.html
                                    sjabloon.html ┘

The 1051 words exist in exactly one place. Correct a typo in the markdown, re-run both scripts, and the stick gets a new file.

Latin books abbreviate. The book prints bonus, ~a, ~um, where the tilde stands for the lemma minus its ending, and a bare ~ stands for the whole lemma (fortis~, fortefortis, forte). The generator expands all of that, and grading accepts both the printed form and the expanded one. Two words refuse to follow the rule and sit as explicit exceptions in the script. One of them is duo, where the stem is du and not duo. The other is ūnus, ~a, ~um; ~ūnius — a seventh printing error, this time in the vocabulary list itself. That tilde should not be there: run through the normal rule it expands to nonsense, and the genitive of ūnus is simply ūnīus. It only turned up because the expansion is done by a script rather than by eye.

Word class is derived, not typed in: verbs from their , ~ō / ~eō / ~iō endings plus the irregulars, adjectives from the shape of the second column, nouns from whatever is left with a real form. That gives 245 verbs, 165 adjectives, 345 nouns and 296 words with no second form at all — 1051, which is the check that the derivation is right.

Each word is asked in up to two directions: Latin → Dutch for all 1051, and Latin → form (“give the genitive of amīcus”) for the 755 that have one. 1806 learning items in total, each with its own progress.

Grading typed answers
#

This is where a vocabulary trainer either earns trust or loses it. Getting told you are wrong when you are right is the fastest way to make a fourteen-year-old close the app.

The rules that matter:

  • Macrons are never required. amici is accepted for amīcī. They are always shown, because he has to be able to read them, but typing them on a normal keyboard is not reasonable.
  • The article is optional. vriend and de vriend are both right.
  • One meaning is enough. For de plaats; de gelegenheid either half scores, and the feedback then shows the full translation with a quiet “Also correct: de gelegenheid.”
  • Parenthetical gloss is not part of the answer. forum (romeins marktplein) is satisfied by forum.
  • A one-character typo is “almost” — Levenshtein distance 1 against an accepted answer of five characters or more. The box does not move, half XP, and the combo survives.

A strict mode exists in the settings for the week of the test, where only the canonical answer counts and “almost” is gone.

The most valuable test in the whole project is the invariant behind this: for all 1806 items, the answer the app itself displays must also be accepted by the app — with and without macrons, in lenient and in strict mode. It is one loop over the data, and it has caught more real bugs than anything else.

A typed question

The learning engine, and the two things it got wrong
#

Underneath is a six-box Leitner system. Correct moves an item up a box, wrong drops it to box 1 (not 0), and each box has a waiting time expressed in both other questions asked and elapsed time — box 3 wants twenty questions and a day, box 5 wants seventy questions and a week. The form question of a word only unlocks once its meaning question reaches box 2: first know what it means, then the form.

One thing is different from every spaced-repetition tool I have used, and it is the difference that made it usable. The engine works only inside the active learning package — a selection of sections he ticks on the home screen — because a school test is always about a part, and 1051 words as one undifferentiated heap is not a study plan. Progress, though, is stored globally per word and direction. Switching packages never loses anything, and a word that appears in two tests is learned once.

Two weeks in, he had two complaints, both of which turned out to be real.

“It’s nearly all multiple choice.” The question form hung purely off the box: boxes 1–2 multiple choice, box 3 and up typing. But box 2 has a ten-minute waiting time, so within one sitting almost the only thing that ever comes back is box 1 — and a wrong answer puts an item back there too. Measured: 27 % typed questions, and in the “weak spots” mode essentially zero, because weak items are by definition in a low box. The fix was to stop deciding on box alone: typing from box 2, a cap on how many multiple-choice questions in a row one item may get, and a floor on the typed share of a round. Plus a setting — how much typing — with three levels, because the right amount is a matter of taste. Measured after: 60–64 % typed on the default, 30 % versus 59 % between the extremes.

“The same word keeps coming back.” The only rule was “never the same word twice in a row”. With at most ten items in the air, a round of fifteen questions showed the same word up to five times, and the measured minimum distance between two turns of one word was one question — the form question of a word sometimes landed directly after its meaning question, because the introduction routine never looked at what had just been asked. Now there is a repeat window of five words, a cap of two turns per word per round, and a layered choice: fresh material first, then repetition with a shrinking window, and if it comes to it, rather one more new word than a third turn for the same one.

A third fix came out of the same session. The combo multiplier was reset at both the end and the start of every round, so a run of ten correct answers always died at the round boundary. The combo belongs to the learner, not to the round — it now carries across rounds and across closing the app, and breaks only on a wrong answer.

None of these were crashes. All three were the kind of bug that makes a tool quietly unpleasant to use, and none of them would have surfaced without someone actually revising Latin with it every evening.

Making it worth opening
#

The learning engine is the point, but a fourteen-year-old does not open an app because it has a well-tuned Leitner scheduler.

The centrepiece is a mosaic of all 1051 words, one small cell each, grouped per caput in its own accent colour. Zero stars is a dark cell, one or two is a faint glow of the caput colour, three or four fills it, and five — every direction of that word at box 5 — turns it gold. Words outside the active package are dimmed but still there. He watches his entire vocabulary list slowly turn gold, and that image does more for motivation than any number.

Getting the star formula right took a revision. Rounding the box average down meant a word at L2N=1, L2V=0 showed as zero stars — “never seen” — for a word he was actively working on, which made the central visual a liar. It rounds up now, with gold strictly reserved for all-boxes-at-5.

Around that sit XP and twenty levels named after Roman ranks (Discipulus through to Iuppiter), a combo multiplier, a daily streak, twenty badges, and a mode called Verover (“conquer”): per section, type every word, 100 % or nothing. Sections vary wildly in size — section 1.0 has 153 words against a median of 20 — so large ones are cut into parts of at most 25 words, giving 59 conquest tests. Take all 59 and a 60-question final exam unlocks.

The conquest screen

And then there are the tesserae: twenty collectible pixel figures in Roman style, each a 16×16 grid drawn on a <canvas> at runtime and turned into an <img> with toDataURL() — no image files anywhere. Each has a Latin name, a rarity, one dry line of flavour text and a single unlock condition, and locked ones show as dark silhouettes with their condition and current count.

The tesserae collection

They were drawn as Python pixel grids, rendered to a contact sheet, looked at, corrected, and injected into the template. “Is it recognisable at 16×16” is an acceptance criterion in the spec, and judging that needed a human eye on a PNG.

The whole thing is themed “Roman mosaic at night”: a deep warm stone background with soft terracotta, purple and gold washes, seven caput colours borrowed from Roman materials, a Greek-key meander as a data-URI SVG tile, Roman numerals in medallions, laurel wreaths built in SVG around the badges. Gold is used for exactly one thing — mastery — and nothing else. Latin is always set in a serif stack and Dutch in the system sans, which sounds fussy but genuinely helps in a translation drill.

Statistics

Specification first
#

The part of this project I would repeat everywhere is that the specification came before the code, and stayed ahead of it. FUNCTIONELE-SPECIFICATIE.md is 45 KB of binding, executable detail: the exact grading rules, the box waiting times, the XP table, all twenty badges with their conditions, the question-selection algorithm as pseudocode, the animation timings, and 25 numbered acceptance criteria at the end.

It is not documentation written after the fact. It is the input. Every change above — the typing mix, the repeat window, the running combo — was made in the spec first, with the reasoning written down as a block quote next to the rule, and only then in the app. Code reviews get a document to review against, and a year from now the answer to “why is it like this” is in the file rather than in my memory.

The whole app was built with Claude Code against that spec, in the same way as the Fri3d badge apps and the rocket launch controller . Six Playwright smoke suites, 71 assertions, green means exit 0.

He is at level 8 and 120 of 259 words in caput 1. Six tesserae of twenty.

Resources
#