Free interactive maths workbooks and games for kindergarten through 5th grade, where every activity also exists as a printable sheet with an answer key. Built for parents at home. No accounts, no ads, nothing to install.
Live: https://izzimath.com/
One activity, three outputs. An activity is authored once as data plus a seeded generator. Three renderers consume it: the interactive player, the printable sheet, and the answer key. Nobody writes a worksheet twice.
The seed is the state. Problems come from a deterministic function of a seed, and
the seed lives in the URL — /books/fraction-number-line/?seed=8817. That one decision
buys infinite practice, reproducible sheets, shareable links, and screen/paper parity,
with no backend and no login. The URL is the save file.
55 activities across K–5 — 38 books and 17 games — covering counting, place value, addition and subtraction with and without regrouping, times tables, fractions on the number line, decimals, area, volume and coordinates.
Three characters (Kiwi the bearded dragon, Georgie the chihuahua, Flame the red panda) re-skin the palette, the word problems and the encouragement copy. They never change the maths — activities are authored character-agnostic with named slots, so adding a character costs one data file and no content changes. "Just math" is a first-class fourth option.
The topic order follows Illustrative Mathematics K–5. The activity choices follow the evidence: linear number board games, fraction number lines, conceptual subitizing, part-whole number bonds, area models, and interleaved review. See docs/EVIDENCE.md for the citations, the effect sizes, the position on timers, and an honest note on where the evidence is thin.
- docs/next/BACKLOG.md — what is left to build. The single list; everything else that mentions remaining work points at it.
- docs/next/README.md — state of play, what the checks cover, and the invariants worth not breaking.
- CLAUDE.md — the same, condensed, and loaded automatically at the start of a Claude Code session in this repo.
npm run verify prints the top of the backlog when it finishes, so it resurfaces
without anyone having to remember it.
Optional, and never forced: the offer appears once, after something is finished, and never again that session if it is waved away.
A child picks a creature from 150 icons, a name from 10 offered, and a secret snack
from a scrolling list of 500, split into everyday snacks and silly ones. That triple is the
whole identity. Scores, completion and download marks, and
the 24 badges hang off it in this browser's localStorage — nothing
is sent anywhere, and no name, email or age is collected. Coming back means picking your
creature out of the ones on the device, then your snack out of six.
The store (src/lib/profile.js) is deliberately shaped like a document database — async
API, document paths, a declared merge rule per field, plain JSON only — so it can be
swapped for Firebase later without touching the callers.
One in six, because six snacks are offered. Not one in five hundred: the size of the pool only makes it unlikely that two children on one tablet pick the same snack. It is not a second factor.
And attempts are unbounded — a wrong pick just re-asks. So the real ceiling is not one in six but one, for anyone willing to tap six times. That is a choice rather than an oversight. The threat model is two siblings sharing a tablet, everything behind the check is a score, and a child shut out of their own progress with no recovery path is a worse outcome than a sibling reading it.
Two rules in foodChoicesFor (content/avatars.js) soften it further, on purpose:
- Same kind. A silly answer gets silly decoys, a plain answer gets plain ones. The pool is 390 silly to 110 plain, so decoys drawn at random would leave a plain answer as the only plain thing on screen. This one is not a difficulty knob — removing it makes the check easier.
- Different silly word and different food. No two of the six share either half, so "Musical soup" never sits beside "Musical hot cocoa". This favours a child who only half remembers, at the cost of making the check easier.
In order of value. Only the first changes the ceiling — the rest raise the effort while unbounded retries still let a determined child in.
- Bound the attempts. In
flowConfirm(src/mount/profile.js), count wrong picks and after three send them back to the character list, or make them wait. This is the only change that stops brute force, and it turns "one in six, eventually" into one in six per visit. It needs a way forward for a child who has genuinely forgotten — "start a new character", said plainly — because a dead end is the failure we are trading against. - More decoys.
CHECK_DECOYSincontent/avatars.js: 5 → 11 gives one in twelve per attempt. One constant, and tried — at 11 all 500 snacks still get twelve distinct options under the eligibility rules, andnpm run verifypasses unedited. It is recognition rather than recall, so a longer grid costs a child little.check.mjsderives its assertion from the constant and needs no edit; the hardcoded6lives intools/func.html, and the grown-ups copy says "checked against six". - Let the six share a silly word. Drop the prefix rule in
foodChoicesFor. Forces recall of both halves; a child who remembers only "the dragon one" is at a coin flip. - Let the six share a food. Drop the base rule. Puts "Dragon pancakes" next to "Moon pancakes" — which is exactly the discrimination the rule exists to avoid, because it is genuinely hard a week later.
- Two snacks instead of one. One in thirty-six per attempt if asked as two six-way
questions, and a pair is memorable. Costs a migration:
profile.foodholds a single id today, and ids are load-bearing (below). - Recall instead of recognition — type or search the snack. Hard to guess and too hard for a five-year-old, and it wants a keyboard. Not recommended.
What we would not do: passwords, anything leaving the device, a recovery flow that needs an email address, or a lockout with no way forward.
One constraint on all of the above: snack ids are permanent. A profile stores the
id, so renaming a silly word or dropping a food locks out whoever chose it. check.mjs
pins the original 25 ids for that reason, and checks all 500 as the answer. Add; never
rearrange.
Plain Node and plain static HTML. No dependencies, no build tooling, no framework.
node build.mjs # generate dist/
node scripts/check.mjs # validate content
node scripts/links.mjs # verify every internal link resolvesnode build.mjs && python3 -m http.server 8890 --directory distcontent/
activities/ one file per grade; each activity is metadata + a generator
characters.js the character packs
routines.js the IM warm-up routines, as generators
feedback.js the suggestion button's copy, and the GitHub issue URL
types.js the nine problem types, and answer checking
roam.js assessment linkage and the score -> practice mapping
src/
lib/ seeded RNG, exact fractions, manipulatives, print sheets
engine/ screen renderers, book player, game player, warm-up player
mount/ per-page entry points
styles/ site.css and print.css
scripts/ build templates, content checker, link checker
build.mjs static site generator
Add an object to the right content/activities/grade-*.js file:
{
id: 'my-activity', title: 'My Activity', kind: 'book', grade: '3',
strand: 'Fractions on the number line', // must exist in strands.js
skill: 'What this practises, in a sentence.',
blurb: 'One line for the card.',
ccss: ['3.NF.A.2'],
roam: [{ task: 'roamMagpi', subscale: 'numberline', block: '0_1' }],
evidence: 'Why this activity exists, and what supports it.',
pages: 12, printItems: 10,
generate(seed, i, ch, r, bookSeed) {
return { type: 'numberline', lo: 0, hi: 1, target: 0.375, targetLabel: '3/8', /* ... */ };
},
}node scripts/check.mjs then validates it: schema, generator determinism across all
five characters, that the answer passes its own checker and appears in its own choice
list, that print sheets and answer keys render, and that every assessment subscale is
still covered. It checks about 1,500 generated problems in a second or two, and it has
caught every content bug so far.
Nine, reused by every activity, so one renderer serves the lot:
choice, input, numberline, compare, tap, ordinal, bond, truefalse,
boardmove.
Pushing to main runs the content checks, builds with BASE=/izzimath, verifies every
link, and publishes to GitHub Pages.
Content and code are for the Yeatman Lab's use; see the repository for details.