Math practice for people who are going somewhere with it.
A self-hosted practice game covering arithmetic, algebra, geometry and trig as one connected subject rather than four separate ones. The measure of success is simple: a student who is proficient across this catalogue is genuinely ready to begin calculus.
Runs on a machine on your own network. Kids reach it from a laptop or a phone with nothing to install; parents get a console to see how it is going.
The versine is an old trigonometric function,
versin θ = 1 − cos θ. It filled the navigation tables that got ships across oceans, and then quietly fell out of use when calculators arrived. It seemed a fitting name for a project about the parts of mathematics that are worth knowing in your bones.
MIT licensed. Use it however you like.
One name per thing, used in the code, the database, the UI and in conversation. If a word appears here, it does not have a synonym anywhere else.
| Term | Means |
|---|---|
| Skill | A named area of practice, e.g. Integer Add & Subtract. Contains Levels. Listed on the Map. |
| Level | One step inside a Skill, teaching one idea. The unit of practice, progress, unlocking and scheduling. |
| Last level | Exactly what it says: the final Level of a Skill. By convention it mixes every Level before it, and clearing it in a Time Trial finishes the Skill for the day. Not a separate kind of thing — a position. |
| Mode | How a Level is practised: Practice or Time Trial. |
| Run | One session of one Level in one Mode. |
| Attempt | One answer to one Problem. Every attempt is recorded. |
| Problem | A single generated question. |
| Map | The screen listing every Skill. |
| Student / Teacher | The two roles. Teachers see the console. |
| depends_on | A soft "builds on" link. Never a gate. |
There is no tier. It was renamed to Level everywhere — including the database columns — precisely so that no one has to translate between what the code says and what the screen says.
There is deliberately no capstone either. "Capstone" has established meanings in education, and this vocabulary should not spend that word on "the last item in a list" when plain words do the job.
Built for two teenagers who are past the age where a cartoon mascot helps.
It runs on one machine on the home network. The kids reach it from their laptops or phones with a browser — nothing to install on their devices.
On the server (needs only Docker):
git clone <your-repo> versine && cd versine
docker compose up -dThen open http://<server-ip>:8000 from any device on the LAN.
The very first screen creates the teacher account — that's the one that can see everyone's progress and remove people.
After that, anyone signs themselves up: the sign-in screen has a New profile tile, and whoever taps it picks an icon and a colour, types a name, and chooses a 4-digit PIN. No grown-up needed to get started. Self-serve profiles are always students, so nobody can grant themselves the teacher role.
A teacher can delete a student from the console (which erases that student's entire history, so it asks twice), and can add a second teacher for another adult. PINs are hashed, never stored in the clear, and rate-limited after five wrong tries — enough to stop a bored sibling, which is the actual threat model on a home network.
To update:
git pull # frontend changes are live on a browser refresh
docker compose restart # …and backend changes after thisweb/ and server/ are bind-mounted, so a rebuild is only needed when
requirements.txt changes (docker compose up -d --build). Progress lives in
data/progress.db on the host and is never touched by either.
The server updates itself. At boot, and again at 04:30, it checks the git remote and — if there is something new and it can be applied safely — pulls it, restarts, and confirms the result actually works.
scripts/install-autoupdate.sh # run once, on the server
That checks prerequisites, installs a systemd timer and service, and starts them. After that there is nothing to do: pushing to the remote is how the server gets new code.
systemctl list-timers versine-update.timer # when it next runs
journalctl -u versine-update.service -n 50 # what it did last time
sudo systemctl start versine-update.service # do it now
curl localhost:8000/api/health # which commit is serving
- Takes a lock. Boot and the nightly run can overlap; only one proceeds.
- Fetches. No remote, no network, or nothing new — it logs that and stops. The app keeps running either way.
- Backs up the database, before any new code can migrate it, using
sqlite's backup API rather than
cp— the database runs in WAL mode and copying the file alone can capture a torn state. Ten backups are kept, each named for the commit it was taken ahead of. - Fast-forwards only. Uncommitted edits on the server, or a diverged branch, mean it declines rather than merging or discarding.
- Rebuilds only if it must.
web/andserver/are bind-mounted, so most updates need no image build — only a change toDockerfile,requirements.txt, ordocker-compose.ymltriggers one. - Proves it works. It polls
/api/health, which opens the database and counts users, so a build whose migrations failed cannot pass. If it does not come up healthy within 90 seconds, the checkout is reset to the previous commit and restarted.
A kid sitting down to practise must find a working app. Every decision
above falls out of that. The update is an improvement on a running system, not
a precondition for one — restart: unless-stopped has the container up long
before the timer fires, so an unreachable remote or a failed update costs
nothing but staying on yesterday's version. When the script declines to update
it exits 0, because declining is correct behaviour; a non-zero exit means a
genuine failure worth looking at in systemctl status.
Set these in the unit file if the defaults do not suit:
| Variable | Default | |
|---|---|---|
VERSINE_BRANCH |
current branch | branch to track |
VERSINE_PORT |
8000 |
where to health-check |
VERSINE_HEALTH_TIMEOUT |
90 |
seconds to wait before rolling back |
VERSINE_KEEP_BACKUPS |
10 |
pre-update backups to retain |
The repository needs a remote to pull from, and at least one commit:
git add -A && git commit -m "Initial commit"
git remote add origin <url>
git push -u origin master
Until then the timer installs and runs happily, finds no remote, and leaves the checkout alone.
Everything — accounts, XP, unlocked tiers, every attempt ever answered — is in
the single file data/progress.db. It is gitignored, so it exists in exactly
one place unless you copy it.
cp data/progress.db "data/progress.backup-$(date +%F).db"SQLite runs in WAL mode, so copy it while the server is stopped, or use
sqlite3 data/progress.db ".backup data/backup.db" to copy it safely while
it's running.
./dev.sh # venv + uvicorn with auto-reload on http://localhost:8000No build step and no frontend toolchain — the browser loads the ES modules in
web/ directly, so a change is live on refresh.
Two skills are built: Integer Add & Subtract and Integer Multiply & Divide, six tiers each.
server/ FastAPI + SQLite. No ORM; the queries are the interesting part.
app.py routes, auth gating, run recording
db.py schema and progress queries
auth.py PIN hashing, session tokens, lockout
web/
engine/ mode-agnostic game machinery
session.js one run: serving, scoring, retry queue, end conditions
modes.js Practice / Sprint / Survival / Mastery as policy objects
scoring.js points, XP, level curve
rng.js seeded RNG, so a run can be replayed from its seed
math/answer.js answer *types* — parsing, comparison, formatting
ui/ screens and the shared visual widgets
visuals.js registry: skill names a visual kind, this dispatches it
skills/ one module per skill ← this is where new content goes
The engine knows nothing about any particular skill. A skill module is responsible for three things and nothing else: generating a problem, saying what a correct answer looks like, and describing how to picture it.
Three files, in this order.
web/skills/<id>.js — what the student sees. Levels with their names,
slugs, blurbs and par times, and the skill's identity. It imports nothing and
computes nothing; it is data.
export const LEVELS = [
{ name: 'Same Denominator', slug: 'same-denominator', blurb: '…' },
{ name: 'All Together', slug: 'all-together', blurb: '…' },
];
export const LAST_LEVEL = LEVELS.length - 1;
export const PAR_SECONDS = [12, 22];
export default {
id: 'frac-addsub',
name: 'Add & Subtract Fractions',
category: 'parts', // must be one the registry declares
glyph: '⁄',
blurb: 'Matching the pieces before you combine them.',
answerInput: 'frac',
dependsOn: ['int-addsub'],
levels: LEVELS,
};tools/generators/<id>.js — how its library gets written. Build-time only;
nothing served to a browser imports it.
export function generate(rng, level) {
return {
prompt: T.asks(T.frac(1, 2, 1), T.op('+'), T.frac(1, 3, 2)),
text: '1/2 + 1/3', // plain text, for the attempt log
answer: { type: 'frac', value: { n: 5, d: 6 }, requireSimplest: true },
parSeconds: PAR_SECONDS[level],
visual: { kind: 'barmodel', /* … */ }, // or null
explain: 'Rewrite both over 6 first…', // sentences become lesson steps
};
}web/engine/registry.js — one import and one array entry.
Then node scripts/build-library.mjs writes the library and warns about levels
too small to be worth drilling. Nothing else in the codebase needs to change.
Two layers. A subject is the broad territory — Arithmetic, Algebra, Geometry, Trigonometry, Chance & Data. A category is a working group of three to ten skills inside it.
Two rather than one because "Trigonometry" and "Calculus" are territories, not
groups. Filing every trigonometric skill under a single heading produces
exactly the twenty-skill bucket that tells a student nothing, which is the
failure the split exists to avoid. check-catalogue.mjs fails a category that
grows past ten.
A subject is where a category is filed, not a claim about which branch owns it. The separation of arithmetic from algebra from geometry is an accident of how textbooks are sold, and several categories genuinely sit in two places: Coordinates is coordinate geometry and it is linear functions; Powers & Roots is arithmetic and it is algebra. Each is filed once, where a student is most likely to look, and a comment beside it says where else it belongs. Nothing in the engine treats a subject as ownership, so no skill is kept from anything by the box it sits in.
Expressions and Equations are separate categories because that boundary is real rather than a size cut. An expression is a thing you rearrange; an equation is a claim you test. Blurring the two is behind a great deal of what later looks like carelessness.
The subject and category travel on the card rather than as headings above the grid: a heading per group forces a row break, and with two skills in a group every row would leave a track empty.
Categories and subjects with no skills yet are declared anyway. They cost a line, they say what the catalogue is for, and they stop the next skill being filed under whichever existing name is least wrong. Empty ones do not render.
A skill filed under a category the registry does not declare — or a category under an undeclared subject — would not appear on the map at all, silently, because the map is built by walking subjects and then categories. That is a typo away at any time, so both are failing checks.
web/math/answer.js exists so that fractions and expressions are not
string-compared. The input widget follows the problem, not the skill —
Improper & Mixed Numbers asks for three boxes one moment and two the next, and
the play screen swaps them without knowing what either is. Enter always
submits; / and Space move between boxes.
A level may set requireSimplest, and the engine enforces it: an answer that
is the right value in the wrong form is refused with the reason — "3/9 is
right, but it isn't in lowest terms yet." Value and form are separate checks,
so a level can demand one without the other.
int, frac, mixed, decimal, choice and expr are defined. Decimals
are held as an integer over a power of ten rather than as a float, because
0.1 + 0.2 is not 0.3 in binary and a drill that marks a correct answer wrong
once has lost the student for the session.
Nodes are exactly what a student sees — Skills, and the Levels inside them. There is no separate vocabulary of tags or concepts to keep in sync, because the things being related are the same things the UI already renders.
// in a skill module
dependsOn: ['int-addsub'], // Skill -> Skills, coarse
levels: [
{ name: 'Sign Rules',
dependsOn: [{ skill: 'int-addsub', level: 1 }] }, // Level -> Level, precise
]depends_on is the gate. A Skill is closed until every Skill it depends
on has been finished — which means clearing that skill's last level against
the clock, the same thing the "done for today" banner has always meant. The
last level mixes every level before it, so it cannot be cleared without the
rest, and nothing new had to be recorded to support this.
This reverses an earlier rule, and the reversal is worth recording because the original reasoning was not wrong so much as incomplete. The edges were advisory on the grounds that a student who wants to jump ahead should be allowed to. What that missed is what a beginner actually sees: twenty skills at once, no indication of where to start, and no way to tell which of them they have the arithmetic for. Gating costs the confident student almost nothing — clearing a foundational skill's last level once is quick if you can already do it, and if it is not quick then the gate has just told them something true. It is a placement test that happens to also be practice.
Levels inside a Skill unlock in order and stay unlocked: level is a
high-water mark that never regresses, so a student returns to a finished skill
and plays only its last level, which is what marks the skill done for that day.
A skill that has not had every level cleared carries a new badge, so
"there is more in here than you have seen" is visible from the map.
A Level may only depend on Skills its parent Skill already declares, so the
precise edges can never contradict the coarse ones. Access is decided by the
coarse edges alone — a level-precise edge never gates a skill, or one level
deep inside a skill could close the whole thing. validateGraph() in
web/engine/registry.js checks that, plus unknown ids, out-of-range levels,
cycles, and that at least one Skill has no dependencies at all; without a root,
nothing is open on day one and the catalogue is unplayable. check-catalogue
then drives the real gate from an empty account and fails if any Skill can
never be reached.
Since an edge now costs access, a connective edge is no longer free. Coordinates had one to Ratio — a gradient is a unit rate, which is true and worth saying — and it put reading a point off a grid behind eight skills. The edge went; the sentence stayed, in the explanation a student reads when they get one wrong. Declare an edge when the second skill is genuinely needed to do the first; say the rest in words.
The edges worth the most are the ones that say this is that move again.
Scaling a ratio is building an equivalent fraction, putting a ratio in
simplest form is dividing out the greatest common factor, and taking a share
of a total is multiplying by a fraction — three notations over one piece of
arithmetic. Left undeclared, a student meets each a second time as a new
topic and learns it twice, which is most of what "bad at fractions" turns out
to mean. So the edge is declared and, where it matters, the picture is the
same picture: quantitymodel and ratiomodel draw the same bar because
3/8 × 24 and "3 : 5 of 24, first share" are the same question.
An earlier design described problems with a faceted tag vocabulary
(rule:sub-a-negative and so on). It was removed: it needed its own grammar,
its own curation policy, and it invited a combinatorial explosion of
relationships between attributes. The graph above says the same useful things
using artefacts that already exist. Every Attempt still stores the literal
problem text, so finer-grained analysis remains possible after the fact if it
is ever actually needed.
The catalogue is the set of problem libraries in web/library/. Those files
are what students are served, and they are the thing to edit when a problem is
wrong: correcting one row is immediate and reviewable, where debugging the code
that produced it is neither.
Generators exist to write a library's first draft — nobody hand-authors a
thousand problems — and to rebuild one wholesale on the rare occasion that is
warranted. They live in tools/generators/, outside web/, and nothing the
server serves imports them.
tools/generators/frac-addsub.js how to manufacture the problems build time only
web/skills/frac-addsub.js levels, names, dependencies shipped
web/library/frac-addsub-*.json the problems themselves shipped
A skill file in web/skills/ now imports nothing at all: it is level metadata
and the graph edges, no logic. scripts/check-catalogue.mjs fails the build if
one starts exposing generate() again.
Rarely, and that is the point. Reach for one when a fault is systematic — when the same flaw affects too many rows to fix by hand, or a level is being redesigned. A single bad problem is fixed in the library.
Because of that, build-library.mjs will not overwrite a library that already
exists; it reports what it would change and leaves the file alone. Regenerating
is deliberate:
node scripts/build-library.mjs # write libraries that do not exist yet
node scripts/build-library.mjs --check # report what the generators would change
node scripts/build-library.mjs --force # regenerate, discarding hand corrections
Determinism is designed in: seeds come from the skill id and level, and rows are sorted before writing, so an unchanged level produces a byte-identical file and a diff shows only what really moved.
scripts/update.sh runs both before it will start a new version, and rolls
back if either fails:
scripts/check-library.py— every row usable: required fields, a known answer type, non-empty accepted forms, no duplicate problems, sane par times, and for a choice row that its correct option is among the options. Python, so a server needs no JavaScript runtime to refuse a bad deploy.scripts/check-catalogue.mjs— the learning graph, and the rules for where a strategy level may sit. Skipped when node is absent.
Neither checks a library against its generator. A hand-fixed library is supposed to have diverged, and a check like that would refuse to deploy the very correction someone had just made.
Most levels drill a procedure. A strategy level drills the choice between
procedures — which form to convert to, which method to reach for, when a
shortcut is worth taking and when it is a trap. It is marked kind: 'strategy'
and shows a "judgement" tag on the map.
Being able to convert both ways and having no idea which one the situation wants is most of what "bad at fractions" actually means. The same is true further up: expanding and factoring are both easy, and knowing that you factor to solve and expand to evaluate is the part that decides whether algebra feels like a machine or a mystery.
Two rules, and the second is the one that is easy to get wrong.
Place a strategy level where the choice first costs something. That is
usually not the skill that introduced the concept. Converting between
improper and mixed numbers is taught in frac-mixed, but the choice only
starts to matter once you are multiplying — so the strategy level sits after
both the conversions and the multiplication, at the first point where picking
wrong is expensive.
Never let a strategy level be where a topic is first met. Working a
judgement question in early, by referring to something the student has not
covered, buys the strategic layer at the cost of teaching the topic badly. If a
strategy level needs a topic, the skill must depend on it — and the level
declares a level-precise dependsOn naming the skill that creates the need.
validateGraph() enforces that the level's dependencies are declared by its
skill, so this cannot drift silently.
Placed correctly, the level lands close to the need and the ideas link together. Placed early, it is trivia.
Strategy levels use the choice answer type: two or more options, picked with
the number keys or the arrows, submitted with Enter like everything else. They
carry no visual — every model leans toward one representation, and a
leaning model answers a question that is entirely about which way to lean.
A student's Level is not accumulated experience. XP in games measures how long you have played rather than what you can do, and it can only ever go up — which misrepresents arithmetic, because a skill you stop practising genuinely does get slower and less accurate.
So Level is computed fresh from present ability, and it can fall:
for every level the student has cleared:
contribution = weight × quality
Level = floor(sum of contributions) + 1
- weight — harder material counts for more. Depth in the dependency graph
(
+0.5per step) times position within the skill (+0.15per level). - quality — how well they perform it now, measured over the last 40 answers at that level. Accuracy multiplies rather than averages, so being fast can never rescue being wrong.
Note what is absent: time. The Level never drifts down because a student stopped playing. Decaying on the calendar would be a guess about what happened while nobody was looking, and this number is meant to record what was actually demonstrated. The Level falls only when a lower standard is genuinely shown, and rises again the moment a better one is.
Because quality is measured over the last N answers rather than the last
N days, a level nobody has touched for months keeps its last demonstrated
standard rather than collapsing to zero.
Nobody sets a clock by hand — not the teacher, not the student, not the author of a skill. It arrives one of three ways:
- Not yet. A level with no history keeps its Time Trial locked. You must practise it first; Practice quietly times you while you learn it.
- Seeded. After 8 answers, the clock is set to the pace you actually
practised at:
median × target. Keep up your own practice pace and you pass exactly. - Adapted. After every trial the clock moves, stored per student per
level in
level_clocks.
The controller reads one signal from each outcome:
| Outcome | Clock next time |
|---|---|
| Ran out of time | +15% |
| Finished with more than 20% of the clock unused | −10% |
| Finished narrowly | unchanged — this is the right level of hard |
Asymmetric on purpose: relief arrives faster than pressure, so a struggling student is helped quickly while a strong one is squeezed gently. Bounded to 3–40 seconds per problem so it can never reach an absurd value, and rounded to 5 seconds so it reads like a clock rather than a measurement.
Accuracy needs no separate rule here — wrong answers eat the clock, so inaccuracy shows up as failing to finish, and the clock loosens in response.
rating.js measures quality against the level's authored reference pace,
not the student's personal clock. If it used the personal clock, every student
would be measured against their own moving bar, everyone would converge on
similar Levels regardless of ability, and comparing two students would become
meaningless. So the gate adapts; the yardstick does not.
Time still matters — it just asks a different question: is this worth revisiting? rather than should this count for less?
A cleared level is fresh, due (14 days untouched) or stale (35 days).
That state never touches the Level. It drives two suggestions:
- the Map flags levels that have gone quiet, and notes that revisiting them re-establishes where the student stands;
- opening a skill whose direct dependency has gone stale offers a one-tap warm-up on that specific level.
Both are prompts, never locks — depends_on is soft, so a student who wants
to press on may. Targeting the warm-up at direct dependencies keeps the
suggestion small and relevant, rather than blocking everything behind a chore
list of everything that has aged.
Full activity history is still recorded for the teacher console; it simply isn't what determines the Level.
A lesson is a worked example stepped at the student's pace, using the
level's own visual. Nothing extra had to be written for it: explain was
already the sentences of an argument, so splitting it gives the commentary,
and the visual holds its asking state until the final sentence — the moment
the answer is being explained rather than merely shown.
1/2 + 2/5 [two bars, different pieces] "Both sides rewritten before they combine."
↓ [still asking] "2 and 5 both divide into 10…"
↓ [bars re-divide, result fills] "Now the pieces match: 9/10."
Because of that, every level has a lesson today — 36 of them, averaging three
steps — with no per-level authoring. A skill can override with its own
lesson(problem, level) when the derived version isn't good enough.
That also imposes a rule on the prose: an explanation must end on its conclusion, since the last sentence is the one the reveal lands on. Two skills had explanations trailing off into an aside after the answer; both were reordered.
A level can point at a recording instead:
{ name: 'Unlike Denominators', lesson: { video: 'lessons/unlike.mp4' } }Where a file exists it is offered; where none does, the walkthrough runs. Videos can be recorded one level at a time, whenever, with nothing to rework in between and no level ever left without something.
- a slim banner on the mode screen, above Practice and Time Trial — deliberately not a third mode card, since that choice was cut to two on purpose and this is what you do before practising;
- the "Why?" button inside Practice, which now steps through the problem actually on screen.
It is an overlay, not a screen, so a run never loses its state because someone
asked how something works. It captures the keyboard while open — Enter or
Space advances, Escape closes — and hands it back on close.
Two, deliberately. Every extra mode is another decision standing between a kid and the maths.
| Mode | Shape |
|---|---|
| Practice | Untimed, unlimited, explanations on tap. Wrong answers just come back around. The place to learn something. |
| Time Trial | Solve the target before the clock runs out and the next tier unlocks. Mistakes cost seconds, not lives. The place to prove it. |
Time Trial is the gate — there's no separate mastery test to sit. A wrong answer is never fatal in either mode: you get another go, and in a Trial the penalty is simply the time it took.
Scoring gives the speed bonus only on a first-try solve, so guessing fast is always worse than answering slowly and correctly. Missed problems return a few questions later in the same session.
Every skill ends with a last level that mixes all the levels before it, in random order with no warning which shape is coming. Earlier levels teach one idea at a time; the last level checks you can tell them apart.
A skill is not done for the day until that last level is cleared in a Time Trial. Practising an easy level all afternoon does not tick the box, and neither does practising the last level without the clock, nor failing the trial. That single rule is what the streak counts: consecutive days finished, not days merely touched. The day strip shows both — solid gold for a finished day, faded for a day practised without finishing.
The skill's tier count travels with each submitted run, so the server knows which level is last without hardcoding anything per skill. Add a seventh level to a skill and it becomes the new finish line automatically.
Each skill names the kind of picture its problems want, and web/ui/visuals.js
dispatches on it. The play screen never learns what kinds exist, so adding a
bar model for fractions means adding one entry to that registry.
Twelve exist so far:
numberline— integer addition and subtraction. Chained hops.signmodel— multiplying and dividing with signs. Splits the problem into the two questions it really is: what sign is the answer, and how big is it? Kids who "can't do negatives" can nearly always do 3 × 4; what they lose track of is the sign, so the widget separates the two and answers neither until the student has. The size row prints whatever it is handed, so the same picture serves signed fractions unchanged — the argument it makes was never about integers, and −2/3 × 3/4 wants exactly the same split.areamodel— multiplying fractions. One square is one whole; the first fraction shades a strip of the width, the second a strip of the height, and the product is where they cross. It carries the two things students most often miss: why you multiply top-by-top and bottom-by-bottom (the grid holdsd1 × d2pieces, the overlapn1 × n2), and why multiplying by a proper fraction makes the answer smaller.fitsmodel— dividing fractions. No area can show division, so this uses length: the dividend laid along a whole, then chopped into copies of the divisor and counted. Getting that idea first is what stops "flip the second one" being a spell.wholesmodel— improper fractions and mixed numbers. Each bar is one whole. Going to a mixed number, the ask shows one piece and says how many of them you have — laying them all out would be the answer. Going the other way, the wholes are drawn as undivided blocks: you can see two wholes and a third, but counting thirds means knowing a whole is three of them, which is the arithmetic.equivmodel— equivalent fractions and simplifying. The same length of bar cut into different pieces. Only the fraction you were given is drawn while the question is open; the second bar would let you count the answer straight off it, so it arrives with the reveal, aligned underneath, where both shadings stopping in the same place is the whole point. A third bar appears for the awkward case:4/6 = ?/9has no whole number to multiply by, so the route runs down through lowest terms and back up, and the picture draws that detour rather than captioning itself with a factor of 1.5.evalmodel— order of operations. The working written out line by line, each labelled with the rule that justified it. While the question is open only the first line shows — that line is the question; everything after it is the answer. The stepped lesson uses the same renderer with one more line each press, which is what makes a walkthrough of this skill worth having.plane— coordinates. An x/y grid with the marks a problem asks about; anything that would answer the question is withheld until one is committed, so the renderer is never handed the point it would otherwise plot.ratiomodel— ratio and proportion. One bar cut into every part with the first share shaded, so 3 : 5 is eight cells with three filled. One bar rather than two on purpose: bars drawn together are stacked and each flexes to the full width, so three parts and five parts would come out the same length — a picture of a ratio that gets the ratio wrong. Cutting one bar keeps the proportions honest and puts both readings on screen at once, 3 to 5 against the unshaded part and 3 of 8 against the whole, which is the confusion the skill exists to settle. It takes the same middle bar asequivmodelwhen neither ratio is a whole number of times the other. It was also the first entry to be an adapter over the shared segments primitive rather than a renderer of its own, which is what the naming lesson below was for.quantitymodel— a fraction of a quantity. The whole cut into the denominator's parts with the numerator's worth shaded, and what one part is worth withheld until an answer is in, since it is one multiplication from the answer. The second adapter, and it is drawn to look like the ratio bar deliberately:3/8 × 24and "they are in the ratio 3 : 5, how many of the 24 are red" are one question in two notations, and a student shown two different diagrams learns them as two topics.comparemodel— comparing two fractions. The bar model's ask-state in a second job: two bars of identical length cut by their own denominators, because you cannot see whether a third beats three eighths unless the wholes are the same length. Recutting both into the common denominator is the answer — once the pieces match you only have to count them — so the recut bars arrive with the reveal.barmodel— adding and subtracting fractions. Two bars of identical length, divided by their own denominators. That alignment is the point: it makes visible that a half and a third are different-sized pieces, which is the misconception behind1/2 + 1/3 = 2/5. Research consistently points at aligned rectangles rather than pie charts for exactly this — you cannot compare a third against three eighths by eye on a circle. On reveal both bars re-divide into the common denominator and a result bar appears.
They all obey the same rule: nothing derived from the answer is drawn until an
answer has been committed. It is structural rather than remembered — a schema
marks the answer-bearing fields and visuals.js strips them before the
renderer is called, so a renderer cannot leak a field it was never given.
While a problem is being asked, only the first hop is drawn: 0 → a. That
anchors where you start without handing over where you finish.
Once an answer is committed, the second hop is drawn from a to the result,
the answer lands as a gold dot, and a green tick or red cross says how it went.
This distinction matters. An earlier version drew the whole chain up front, which quietly turned the teaching aid into an answer key — you could read the result off the picture without doing any arithmetic.
Every screen inside a skill carries a breadcrumb trail — Map / Integer Add & Subtract / Adding Negatives — and each part is clickable, from the run screen
and from the summary alike. Leaving a run part-way through still files it away
first, so nothing practised is ever lost by navigating away.
Every sound is synthesised with the Web Audio API — no files to download, nothing to go missing offline, and the whole palette costs a few hundred bytes rather than a folder of samples.
| Moment | Sound |
|---|---|
| Countdown | three beeps at 660 Hz, then 990 Hz for go |
| Correct | two rising notes, 880 → 1319 Hz |
| Wrong | a short low buzz, 174 Hz |
| Final five seconds | a clock tick each second |
| Trial cleared | a rising four-note arpeggio |
| Ran out of time | two falling notes |
Two rules shape it. Short — these fire dozens of times a session, and anything with a tail is unbearable by the twentieth problem. And the wrong-answer sound is deliberately soft: a harsh buzzer every time a child makes a mistake teaches them to fear the mistake, which is the opposite of what Practice is for.
Mute lives in the top bar of every screen including mid-run, since that is when you would most want it. It is stored per device rather than per account — it is about the room you are sitting in — and the button suppresses mousedown so it never pulls focus out of the answer field.
Committed to, and not to be revised casually: a notation a student half-learns is worse than either alternative.
| meaning | typed as | also accepted |
|---|---|---|
| exponent | x^2, x^(n+1) |
x**2 |
| square root | sqrt(2) |
√2 |
| other roots | root(3, x) |
|
| fraction | 1/2, (x+1)/2 |
|
| multiplication | 2x, 2(x+1) |
2*x |
| constants | pi, e |
|
| absolute value | abs(x) |
|
| grouping | ( ) |
^ rather than Python's ** because ^ is what Desmos, WolframAlpha, a
TI-84 and LaTeX all use; Python is the outlier, and ** is an easy thing to
meet later for someone who already understands ^. Square brackets and braces
are deliberately not accepted as grouping — they are wanted for intervals and
sets.
Input is permissive, display is not. Several spellings are accepted and exactly one is drawn, which is the same split the libraries already make: an answer stores a list of acceptable forms and one canonical rendering.
Most answers are typed into shaped fields rather than a single box — a
fraction has a numerator above a denominator, a power has a raised exponent.
That is not only kinder on a phone keyboard, where ^ sits two layers deep;
it puts the structure of the answer in front of the student, which for
notation being learned is part of the teaching.
The keys that move between those fields are the syntax itself. / moves from
numerator to denominator, ^ moves into the exponent, sqrt opens a radical.
So a student who types 3/4 or 2^5 gets the right shape without knowing
they typed anything, and the same keystrokes work later when the fields go
away. Nothing has to be unlearned.
Shaped fields tell a student the shape of the answer, which is fine while the shape is a property of the level — every answer on "Multiplying Powers" is a single power, and one problem teaches that. It stops being fine when the shape varies within a level, which is also usually a sign the level is doing two things at once.
Unstructured entry is a skill in itself, and one worth having: a calculator,
Desmos and a physics problem set all want a typed expression. So a level may
declare entry: 'free' and take a single box instead. Used sparingly, and
late — the same move as the "All Together" level that removes the topic
scaffolding, applied to the input instead.
Free entry makes the accept list load-bearing: with shaped fields, 2^5 and
32 are told apart by which box was filled, and in a single box they are two
strings that both have to be judged. That is the form-versus-value question
again, and requireSimplest already models it.
Unstructured entry is unnerving because nothing tells you whether the system
read what you meant. So a level using entry: 'free' renders the input back
as notation while the student types.
It renders structure, and never evaluates. This is the rule the feature lives or dies by:
| typed | renders | not |
|---|---|---|
2^1/2 |
2¹/2 |
√2 |
4/8 |
4/8 |
1/2 |
2+3 |
2+3 |
5 |
The first row is the whole point — it shows the exponent bound tightly, which is the ambiguity worth surfacing. The other two are the trap. A preview that simplifies or evaluates does the student's work and hands back the answer, and it would be easy to build by accident: a parser that returns a value is more natural to write than one that returns a faithful tree. The bug would look like a nice feature until you noticed nobody was learning anything.
Rendering only what was typed also keeps the preview clear of reveal discipline. It reflects the student's own input, so there is nothing in it to leak.
It goes in a strip immediately above the answer box, not in the blank slot of the prompt. Two reasons. The screen runs prompt, visual, input, keyboard, so anything below the input can end up under the keyboard, and anything as far up as the prompt is a real head-move while typing. And the blank slot already has a job -- it is where the correct answer lands on reveal -- so putting live input there would give one place two meanings, mutating from "what I typed" into "what was right" at exactly the moment a student got it wrong.
While typing, be forgiving: 2^ is not an error, it is somebody mid-keystroke.
Hold the last thing that parsed, or show a muted placeholder. Never flash red
before submit -- a preview that scolds you while you are still typing teaches
people to stop looking at it.
The renderer for this already exists: renderPrompt turns terms into notation
today, so a parser emitting terms feeds it directly. The preview is the parser
plus a little wiring, and it doubles as the parser's test surface -- a page
listing input strings against their rendered interpretations, in the shape of
web/dev/prompts.html, is how precedence and grouping get verified before a
student meets them.
The answer box is clamp(1.6rem, 6vw, 2.2rem) with 8px of padding — around 45
to 55px tall, comfortably above the 44px that counts as a reliable touch
target. A display exponent is set at .55em, which as an input would be 15 to
19px: too small to hit on a phone.
So an exponent field shrinks its font and raises its baseline, but keeps a tappable box. The size ratio does the notational work; the target stays fingerable.
The answer field keeps focus for the whole run: buttons suppress mousedown so
they can't steal it, clicking the card returns it, and any stray keystroke puts
it back. Enter submits, Enter again skips the reveal, ? explains, Esc
quits. On the summary, Enter runs it again and Esc steps back.
The teacher console's Ability by level panel answers one question: how able is this student, on a yardstick that is the same for everybody?
For each level, per student:
- median seconds on correct answers, against the level's fixed standard
- their clock — where the self-adjusting Time Trial gate has settled for them, and over how many trials
- accuracy — share of answers that were right
- a trend: one bar per day, green below the standard and red above it, with the standard drawn as a dashed line
Median rather than mean, deliberately: one interrupted problem produces a 400-second outlier that drags an average into nonsense.
The verdict chip reads fast / at standard / near standard /
below standard. Note what it is not: a warning that the clock is set wrong.
The clock corrects itself now, so that question answers itself. This is a
statement about the student.
The two numbers are different on purpose. A student's own clock adapts to them, so it can never tell you whether they are strong or weak — everyone eventually passes their own bar. The median against the fixed standard can, and it stays comparable between siblings and across months.
Skills the engine is built for but that aren't written yet: scientific notation, sequences, functions, and then the three subjects the map declares and nothing yet fills — geometry beyond coordinates, trigonometry, and chance and data. Ratio and gradient are the rungs trigonometry stands on and both are in, so that is the order to take them in.
Two things worth not repeating. Negatives were taught in the Integers category
and then never used again: for a long stretch the catalogue held tens of
thousands of problems across fractions, decimals, percents, ratio and
equations without a single negative answer among them, so a student drilled to
fluency on −7 + 3 had still never solved x + 7 = 3. Nothing caught it,
because every check asks whether a problem is well-formed or right and none of
them asks what the catalogue as a whole never does. A gap of that shape is
invisible to a test suite and shows up only by counting — which is worth doing
deliberately, now and then, along whichever axis a skill might have been
quarantined on.
The second is narrower and has the same cause. A skill can teach every idea it names and still never present some ordinary shape of the problem, and a student meeting that shape for the first time in an exam has effectively met a new topic. Adding fractions never once summed to exactly one. Solving equations never had an answer of nought. Nothing anywhere did arithmetic on a mixed number, though a strategy level asked which form to use before dividing 2 1/2 by 2 — a judgement about an operation the catalogue could not perform. None of these are bugs and no check will ever find them, because each level does correctly what it says it does. They are found by asking, of a skill you believe is finished, what it never asks.
Ideas parked for later: live head-to-head races over the LAN (the server is already the right shape for it), a spaced-repetition "daily mix" across skills, and cosmetic unlocks bought with XP.
A Survival mode (three lives, escalating difficulty) was built and then removed when the mode list got too long. It's in the git history if it's ever wanted back.