Spaced repetition tells you when to review a kanji. It says nothing about whether you were ready to learn it in the first place, and it says even less about the example words a flashcard shows you underneath. Kanji Climb is the little trainer in my Japanese study cluster that tries to be careful about both. Most of that care lives in one Python build script that, for every one of about 2,300 kanji, hand-picks two or three real example words made entirely of kanji you have already met.
The ordering problem
A vocabulary list sorted by frequency is honest, but it can be rough to learn from. The most common words are common precisely because they appear everywhere, which means they freely contain kanji a beginner has never seen. Drop someone at word one and you are asking them to read characters that, in any sensible teaching sequence, belong hundreds of lessons later. Frequency is the right answer to "which words matter most." It is the wrong answer to "which word should I see next."
So the order of the kanji themselves is not something I tried to invent. The trainer follows the Kodansha Kanji Learner's Course (KKLC), Andrew Scott Conning's pedagogical sequence: common kanji first, components introduced just before the characters that depend on them, visually related kanji grouped together. The build script flattens KKLC's sections and chapters into one ordered list and assigns each character its index, the klc field, which becomes that kanji's rank for the rest of the pipeline.
Merging two open datasets
The pedagogical order is one input. The facts about each kanji are another, and they come from a second source. The script merges:
- topokanji, a topological ordering of kanji by component dependency. This is where the "learn this, then this unlocks" graph comes from: which radicals and simpler characters a given kanji is built out of.
- kanji-data (KANJIDIC2 plus JLPT and WaniKani metadata): meanings, on-readings, kun-readings, JLPT level, school grade.
Readings need a little cleaning before they are usable. KANJIDIC2 stores on-readings in katakana and kun-readings with dots marking okurigana boundaries, so a tiny normalizer strips the punctuation and shifts katakana down into hiragana by the fixed 0x60 codepoint offset between the two kana blocks. Meanings get lowercased, trailing parentheticals dropped, and anything tagged as a radical name filtered out. The result for each character is a compact card.
{ "c": "日", "m": ["sun", "day"], "on": ["にち","じつ"],
"kun": ["ひ","か","び"], "deps": ["口","一"],
"vocab": [ ... ], "klc": 1, "jlpt": 5, "grade": 1 }
The deps array is transitively resolved and filtered down to characters that are actually in the learnable set, so every dependency a card points at is itself a card you will encounter. That is what lets the app draw an honest "prereqs" and "unlocks" graph in its pathway view.
Gating the example words
Here is the part I find most satisfying. A kanji card is more useful when it shows the character living inside a real word, but example words are exactly where you can quietly betray a learner. The word 漆器 (lacquerware) is a lovely illustration of 漆, but only if you can already read 器. Show it on day three and the example teaches confusion.
So the build indexes every common word from JMdict by the kanji it contains, then, for each character at its position ci in the KKLC order, keeps only the words whose every other kanji appears earlier in the order. The constraint is the whole point: not "at least one kanji is known," but "all of them are."
def pick_vocab(c, ci):
eligible = []
for k_form, kana, meaning, ks_in_word, sc in kanji_words.get(c, []):
ok = all(
order_index.get(k, BIG) < ci
for k in ks_in_word if k != c
)
if ok:
eligible.append((sc, k_form, kana, meaning))
eligible.sort(key=lambda t: -t[0])
return [first 3 distinct meanings...]
The all-versus-any distinction turned out to be the quiet load-bearing decision. If a word qualified the moment a single one of its kanji was known, learners would constantly hit words they could only half read, and a half-readable example feels like a bug. Requiring the full set means every example word is genuinely decodable, even when its reading is new.
Ranking the candidates that survive
Plenty of words usually pass the gate, so the script has to choose well among them. Each candidate gets a composite score before the top three distinct meanings win a slot:
- Frequency of the rarest kanji in the word. Using the minimum rather than the average means a word made of two common kanji beats a word that pairs a common kanji with an obscure one. The chain is only as strong as its weakest character.
- JMdict priority tags. Forms marked
news1,ichi1, orspec1get a bonus, and the numberednfXXrank is folded in on a sliding scale sonf01contributes more thannf50. - Length penalties. Four-or-more-kanji compounds lose points for being obscure, and single-kanji words lose a smaller amount, since the card already teaches that reading on its own.
Frequency still matters, in other words, but only as a tiebreaker among words that are already safe to show. It decides the ordering inside the eligible set; it never gets to override the gate. Of the 2,299 kanji, about 2,209 end up with at least one example word and roughly 1,646 get a full three, which feels like the right shape: the rarest characters simply have fewer common compounds built only from earlier kanji.
Build time versus study time
All of the expensive work happens once, offline, in the build pass: flatten KKLC, resolve dependency graphs, index tens of thousands of JMdict words by character, score and gate the candidates. The output is a single roughly 240 KB JSON file, emitted twice so the page can run either from a local server or straight off the filesystem.
The curriculum is computed once and is the same for everyone. The state is small, personal, and the only thing that moves.
At study time the app does almost no thinking about the curriculum. It keeps per-character progress in localStorage, tracked separately for meaning mode and reading mode, and unlocking is deliberately simple: a kanji becomes available once everything earlier in the KKLC order has been seen at least once. That keeps the ladder linear (you cannot skip ahead) without gating progress on perfect recall.
function isUnlocked(entry, mode) {
const idx = state.indexOf.get(entry.c);
if (idx === undefined || idx === 0) return true;
return idx <= state.maxSeenIdx[mode] + 1;
}
The spacing itself is a stripped-down SM-2 with a fixed ladder of intervals: a lapse re-shows in 30 seconds, then correct answers step out through 4 hours, 1 day, 3 days, 7 days, 16 days, and from there multiply by 1.9 each time, capped at 180 days. Nothing exotic, but it is enough, and because the curriculum is frozen in the dataset the runtime only ever reads a position and looks up what that position unlocks. Cheap to compute, easy to reason about, simple to inspect when something looks off.
What I took away
- Borrowing a hand-curated order like KKLC beat anything I would have invented from stroke counts or school grades. The pedagogy is the hard part, and someone had already done it well.
- Gating example words on all of their kanji, not any, was the decision that made the cards trustworthy. A half-readable example reads as a mistake.
- Scoring by the rarest kanji in a word, rather than the average, is a small trick that keeps examples genuinely common rather than common-plus-obscure.
- Doing the expensive structure once at build time and letting a tiny per-mode state decide what you see kept the runtime almost boring. The build is the brain; the cursor is the only moving part.
The payoff is a deck that, so far, tends to feel reachable. You climb toward common words, and when one finally appears as an example, you can already read everything it is made of.