Fork me on GitHub
Build it yourself

advanced multi-week education

Build your own learning platform
(an IXL alternative)

IXL is wonderful: it delivers one question at a time, a score that climbs, and a schedule that decides what you practise next. None of that is hard to copy. What you cannot copy is the answer history it has collected from millions of children, or its curriculum-aligned content library. This build gives you the machine and leaves you the content problem, which is the honest shape of the work.

What you'll learn

  • Generate a question from a skill, a seed and a difficulty band, so the server can re-derive the answer at grading time and never send it to the browser
  • Keep answer history append-only, enforced by the database, and rebuild every learner's schedule from it when you change the algorithm
  • Write a scheduler as a pure function over exact arithmetic, and prove that rebuilding twice gives the same result
  • Design one item model that holds vocabulary or dates as comfortably as arithmetic, so the second subject is content work rather than a migration
  • Ship a Django app that loads no third-party asset, which is what lets you tell a school where every byte comes from

Before you start

  • Comfortable with Python, the Django ORM, and writing a migration by hand
  • Docker on your machine, and somewhere to run a container that you control
  • Willing to author question grammars and debug them against a verifier, which is most of the work after week one

The build

DELEGATE

Start from the shape that already works in production for us: Python 3.12, Django 5.1, five pinned dependencies, SQLite on one volume, and front-end assets vendored into the repo instead of linked from a CDN. Jinja2 renders the pages and Django templates are left to the admin. The point of vendoring is not speed, it is that you can tell a school exactly what the browser loads.

step prompt
Set up a Django 5.1 project called practice, for a spaced repetition learning app. Requirements:

- Python 3.12, and a flat requirements.txt pinned to majors holding only Django, Jinja2, PyYAML, gunicorn and whitenoise
- SQLite as the only database, with OPTIONS init_command setting PRAGMA journal_mode=WAL, busy_timeout=5000 and synchronous=NORMAL, plus transaction_mode IMMEDIATE, and the file under a DATA_DIR env path
- Two template engines: Jinja2 with APP_DIRS off for pages, and the Django engine reserved for django.contrib.admin
- Refuse to boot in production without DJANGO_SECRET_KEY, and derive ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS from a PUBLIC_URL env var
- WhiteNoise serving static files, with collectstatic run inside the image at build time
- A single stage Dockerfile on python:3.12-slim, VOLUME for the data dir, and an entrypoint that migrates then runs gunicorn
- Download startr.style and startr-swap into static/vendor/ and record each file's sha256 in a checksums file, so a drifted asset is a failed check and not a surprise
- Out of scope: any other dependency, any JavaScript build step, and docker compose
WE

A skill is a small grammar file: named symbols with integer ranges, a question template, an answer expression over those symbols, and invariants that reject a bad draw. Expanding it with a seeded generator gives you a question and its answer from the same bindings, which is what lets the server grade without storing millions of rows. Use exact rational arithmetic throughout, because a float turns a correct answer into a wrong one at the seventeenth decimal place. Watch this step carefully: it is where a whole corpus can quietly become unreproducible.

step prompt
Add a question generator to the practice app, expanding a skill grammar deterministically. Requirements:

- Skills are JSON files with id, grade, title, symbols, difficulty bands, origin template, answer expression and invariants
- Symbols are an ordered map where each value is an integer range, a choice list, or an arithmetic expression over earlier symbols
- Seed a mulberry32 generator from a hash of skill id, seed and difficulty, and implement it yourself over integers so the corpus never depends on a language runtime detail
- All arithmetic goes through fractions.Fraction, never float, and the answer is returned as an exact value plus a rendered string
- Evaluate expressions by walking a parsed syntax tree against a whitelist of nodes and operators, and never call eval, because a skill file is untrusted input
- A draw that fails its invariants is skipped by incrementing the seed, so expansion stays a pure function of its inputs
- Same inputs must always give the same question and the same answer, proven by a test on fixed seeds
- Out of scope: rendering maths for the browser, and storing anything in the database
WE

Without this command, "the questions are correct" is a feeling. With it, you can change a grammar and know within a minute whether you broke it. This is also what makes model-authored grammars safe later: the verifier is the gate, so a generated skill either passes or never ships. Run it in CI and treat a failure as a blocked release.

step prompt
Add a verify management command for the skill grammars written in the previous step. Requirements:

- Expand every skill across 1000 seeds and all five difficulty bands
- Assert that no expansion leaves an unresolved symbol token in the question text
- Assert that every answer parses as an exact integer or rational value
- Assert that every expansion satisfies the skill's own invariants
- Assert that the same seed and band produce identical output on a second expansion in the same run
- Print a per skill summary with counts of expansions, skipped seeds and failures
- Exit non-zero on any failure so continuous integration can gate on it
- Out of scope: measuring question difficulty, and any judgment about pedagogy
WE

Two decisions here, and both are hard to change later. First, the attempt table is immutable, enforced by database triggers rather than good intentions, and it stores the rendered question and the derived answer as text rather than only the seed, because a grammar you fix tomorrow makes yesterday's seeds meaningless. Second, attempts reference a Learner row you own forever, never the auth user, so changing how people sign in later is an update to one nullable column instead of a rewrite of your most protected table. That indirection is also how you answer a deletion request: drop the Learner link and the history is anonymous without breaking append-only.

step prompt
Add the record layer to the practice app, holding learners and their attempts. Requirements:

- A Learner model with a stable id, a display name chosen by an adult, a nullable link to a Django auth user, and a nullable external identity id for a later single sign on
- An Attempt model referencing Learner, with an opaque item reference string rather than separate skill and seed columns, plus the rendered question, the derived answer, the raw learner input, the verdict, elapsed milliseconds and a created timestamp
- Attempt also stores the content hash of the skill definition it was generated from, so a later accuracy change can be told apart from a grammar edit
- A migration adding SQLite triggers that abort on update or delete of an attempt row, and a queryset that raises on update and delete so the ORM refuses first
- Write the trigger migration to no-op on any other database vendor rather than raising, so a future move to Postgres is not blocked by migration history
- A test that proves an update and a delete both fail, at the ORM and at the database
- Out of scope: scheduling, grading, and any view
DELEGATE

Scheduling state is derived, not authored: given a learner's attempts in order, the schedule is whatever the algorithm says it is. Keep the algorithm a pure function and the state a JSON blob rather than columns named after one algorithm, and swapping SM-2 for FSRS later becomes a rebuild rather than a migration with an apology email. Prove the rebuild is idempotent, because that test is the entire payoff of the previous step.

step prompt
Add SM-2 scheduling to the practice app on top of the Attempt record. Requirements:

- A pure function taking current state and one attempt, returning the next state, with no database access inside it
- State is a small JSON structure holding ease, interval, due date, repetitions, lapses and a difficulty band, under a named algorithm version
- Grade quality from the verdict and elapsed time: wrong is the lowest, correct and slow is middling, correct and fast is the highest
- Standard SM-2 behaviour: a wrong answer resets the interval to one day and lowers ease with a floor, otherwise intervals step one day, then six days, then the previous interval times ease
- Three correct answers in a row raise the difficulty band by one to a maximum of five, and a miss lowers it by one to a minimum of one
- Scheduling state hangs off a learner and a target reference, not a skill foreign key, so authored cards can be scheduled per item later
- A rebuild_schedules management command that discards all scheduling state and replays every attempt in order
- Tests proving the pure function against fixed inputs, and proving that rebuilding twice produces identical rows
- Out of scope: any alternative algorithm, and any user interface
DELEGATE

One question at a time, an input, a verdict, and a next question. The grading endpoint takes the item reference and the learner's input, re-expands the question from the seed, compares exactly, appends an attempt and returns the verdict. The answer is never in the page before the learner has earned it, which is the whole reason grading lives on the server. Make it work with JavaScript switched off first, then let the swap library replace only the question region, so a phone on a school network stays usable.

step prompt
Add the practice interface to the app, using the vendored startr.style and startr-swap assets. Requirements:

- A home page listing skills with a count of items due now for the signed in learner
- A practice page showing one question, a text input, a check button, and after answering the verdict, the correct answer and a worked solution
- A grading view that takes the item reference and the raw input, re-expands the question server side, compares as exact rationals so two quarters equals one half, appends an Attempt and updates scheduling state in one transaction
- Normalize input before comparing: trim spaces, accept a decimal that is exactly equal to the fraction, accept a mixed number, and reject nothing on formatting alone
- Never include the answer or the solution in a response for a question the learner has not yet answered
- Plain form posts that answer with a redirect so the page works with JavaScript disabled, and a single swap region around the question so it updates in place when JavaScript runs
- Mobile first inline style props, tap targets at least 44 pixels, and a visible streak, difficulty band and mastery bar
- Out of scope: any framework, any CDN asset, and any animation
BY HAND

The deploy is the easy half: build the image, create the app, point a domain at it, let the platform issue a certificate. The half people skip is deciding what you are, legally, before a child's performance data exists. In a school pilot the school is the controller and you are the processor, which means a written agreement, no use of learner data for your own purposes, and a deletion path you have actually tested. Decide the age floor, who consents, how long you keep attempts, and who gets called if something goes wrong. Write it on one page and keep it with the repo.

Where things stand

  • No tutor chat. A model that can see the answer will eventually hand it over, and that makes your practice data worthless. Ship the drill loop first.
  • No self-serve signup. Accounts are created by a teacher or parent, which keeps consent and the credential set small.
  • No content library. You author every skill grammar by hand, and that is the real cost: four skills is a weekend, a useful maths product is hundreds.
  • No classroom reporting, rosters, or parent dashboards. Django admin is the whole staff surface here.
  • No curriculum alignment. IXL sells standards coverage per grade and per region, and that is a content licensing business, not a feature.

Why people pay for the original and what that teaches us

proprietary-data: IXL's real asset is the answer history of millions of children: which wrong answer follows which question, and how long each took. That tells them what to ask next better than any published algorithm can. You start with zero of it, so use a published scheduler and keep every attempt from day one, because history is the one thing you cannot backfill later.

content-rights: Curriculum alignment is a licensing and editorial operation, not code. Matching a state or provincial standard means paying people who know it and keeping up when it changes. Generated arithmetic sidesteps this for one subject; the moment you want reading comprehension, you are buying or authoring content and the economics change completely.

execution-polish: The difference between a demo and something a ten year old will use twice is answer normalization. Marking 0.25 wrong when the answer is 1/4 punishes a learner for being right, and it also writes a false lapse into their schedule, so the damage outlives the moment. Budget real work for parsing input, and log every wrong verdict with the raw text so you can audit it.

Stretch goals

  • Export a deck as tab separated cards that import into Anki with working maths and no add-ons, which gives learners something that outlives your server
  • Add FSRS beside SM-2 and rebuild every schedule from the attempt log, which is the moment the append-only record pays for itself
  • Add a second subject with authored cards rather than generated questions, and see whether the item model really held

About this project

Sage Practice is the spaced-repetition practice system Sage.Education is building on the same stack as Sage Trellis, our CRM: Django 5.1 on Python 3.12, SQLite in WAL mode, Jinja2 pages, and startr.style with startr-swap vendored locally rather than pulled from a CDN. Mathematics is the proof of concept because arithmetic can be generated and graded exactly, but the item model is deliberately subject-agnostic. The design is settled and the build is not finished, so this lesson is that design at a size one person can carry to a working system in a few weekends.

Sage Practice is ours. Sage.Education builds and runs it, and this lesson was written from that build. It does not come from a canivibecodeit entry.

Sources & further reading

  • SuperMemo 2: the original SM-2 algorithm — The source for the interval and ease rules, written by the author. Short enough to implement directly from.
  • Python fractions module — Exact rational arithmetic in the standard library, which is what keeps grading honest.
  • SQLite write-ahead logging — Why WAL mode lets a web process and a worker share one file without blocking each other.
  • Django database transactions — For the grade-and-schedule write path that has to be one transaction.
  • FSRS, a modern scheduler — The algorithm you will probably want after SM-2, and the reason to keep scheduling state as a blob.
  • KaTeX — Maths rendering for when arithmetic is not enough. Vendor it rather than loading it from a CDN.
  • startr.style — The inline custom property framework used for the interface, and the source of the vendored stylesheet.

Finished alternatives (if you'd rather not build)

  • IXL — Per-skill drilling with the SmartScore progress bar this design borrows from, sold by subscription per child or per school.
  • Anki — The reference spaced-repetition app. Free, open source, and card-based, so you author every card instead of generating questions.
  • Khan Academy — Free exercises with mastery tracking across a full curriculum, funded by donations rather than subscriptions.

Keep building

New lessons and honest build notes, by email. No spam, one-click out.

Signups open when the site goes live.