Most kanji are not atomic. The character for rain (雨) sits inside the character for snow, electricity, cloud, and dozens of others. So if you study kanji in the wrong order, every lesson is harder than it needs to be: you are asking yourself to memorize a shape made of pieces you have never seen. I built a little keyboard-driven trainer I call Kanji Climb around one stubborn idea. You climb the set in order, and a character does not appear on screen until you have already met everything that comes before it.
Kanji are a graph, not a list
The first instinct when you build a study tool is to sort your items into a flat list and march through them: frequency order, JLPT order, school-grade order. Those orderings are all useful and each has its place, but they tend to set structure aside. The character for snow (雪) is the rain radical sitting on top of a broom. Electricity (電) is rain over a different lower component. If you meet snow before you have ever drawn rain, you are memorizing an unfamiliar whole instead of recombining familiar parts.
So the data behind the trainer treats kanji as a directed graph. Each character points at the simpler components it is composed of, and those components point at their own. That graph is what powers the "pathway" view, where every character shows you both its prerequisites and the characters it unlocks, and you can click any chip to walk the graph one hop at a time. The interesting realization for me was that the graph and the order are two different problems. One tells you what a character is made of; the other tells you when to teach it.
Two jobs, several open datasets
I did not have to invent any of this, which was a relief. A few open datasets carried the pieces I needed, and the build script's whole job is to glue them into one file:
- Order comes from the Kodansha Kanji Learner's Course (KKLC), a hand-built pedagogical sequence. It deliberately introduces a component just before the characters that use it and groups related kanji together. I tried a pure topological sort first, and it was technically correct but read like a parts list. A curriculum built by a human who teaches this for a living was simply better company.
- Meanings and readings come from KANJIDIC2 metadata: English meanings, on-yomi, kun-yomi, JLPT level, and school grade.
- The component graph comes from a separate topological dataset that maps each kanji to its direct structural dependencies. I keep this purely for the pathway view, not for ordering.
- Example words come from a common-only release of JMdict, the open Japanese-English dictionary.
Merging them produces one array of about 2,224 entries. A single entry is small and flat:
{
"c": "\u96e8",
"m": ["rain"],
"on": ["\u3046"],
"kun": ["\u3042\u3081", "\u3042\u307e"],
"deps": ["\u4e00", "\u53e3"],
"vocab": [{ "w": "\u96e8", "r": "\u3042\u3081", "m": "rain" }],
"klc": 1, "jlpt": 5, "grade": 1
}
The array is sorted front to back in KKLC order, which buys one property worth stating plainly:
For any card in the set, every character introduced before it has already appeared earlier in the array. Iterate front to back and you are always standing on familiar ground.
Examples that only use what you already know
The part I am most fond of is how example words get chosen, because it leans on that ordering in a concrete way. When the build script considers a candidate word for a character, it walks every other kanji in that word and checks its position in the learning order. If even one of those kanji comes later than the current card, the word is thrown out.
# every other kanji in the word must come earlier than this card
ok = True
for k in ks_in_word:
if k == c:
continue
j = order_index.get(k)
if j is None or j >= ci: # unknown, or not learned yet
ok = False
break
The effect is that when you finally reveal a card and see its example compounds, you can actually read the rest of each one. Among the eligible words I rank by a small composite score: modern news frequency of the rarest kanji in the word (so a word made of two common characters beats one with a rare straggler), plus JMdict priority tags like news1 and the nfXX frequency band, minus a penalty for four-plus-kanji compounds that tend to be obscure. It is not fancy. It just nudges the most useful, most readable examples to the top.
Unlocking, and a small spaced-repetition engine
Order is only half the design; the other half is the exact moment a character becomes available. The rule I settled on is sequential and forgiving: a kanji unlocks once every character before it in the curriculum has been seen at least once in the current mode. In code that is almost embarrassingly small, because the order already did the hard work.
function isUnlocked(entry, mode) {
const idx = state.indexOf.get(entry.c);
if (idx === undefined || idx === 0) return true;
// available once you've reached the character just before it
return idx <= state.maxSeenIdx[mode] + 1;
}
I went back and forth on whether to gate unlocking on mastery (say, two clean repetitions of everything earlier) rather than mere exposure. Mastery-gating sounds rigorous, but in practice it stalls you: one stubborn character can wall off the entire rest of the set. Seen-once keeps the ladder strictly linear, you still cannot skip ahead, while letting the spaced-repetition scheduler handle the actual remembering. Recall and unlocking are different concerns, and tangling them made the tool worse.
The scheduler is a trimmed SM-2. A correct answer steps the card up a fixed ladder of intervals; a miss resets it to a 30-second lapse so it comes right back the same session.
- 1 rep then due in 4 hours, 2 reps in 1 day, 3 in 3 days, 4 in 7 days, 5 in 16 days.
- Past that, the interval multiplies by 1.9 each time, capped at 180 days.
- A wrong answer sets reps back to 0 and re-shows the card after 30 seconds.
- A card is considered "known" once it reaches 5 reps; the pathway grid colors it accordingly.
Card selection follows the same priority every time: serve the most-overdue seen card first, and only when nothing is due reach forward to the next unlocked-but-never-seen character in order. Reviews always win over new material, which is exactly how I want a study session to feel.
Two skills, tracked separately
Knowing what a character means is a different skill from knowing how it sounds, so progress is tracked in two independent buckets: meaning mode and reading mode. The persisted state is just { meaning: {...}, reading: {...} } keyed by character, saved to localStorage under one key. You can be comfortable recognizing a meaning while still fumbling the readings, and a shared counter would let strength in one quietly hide a gap in the other. Resetting one mode leaves the other untouched.
Answer matching has to be a little generous in each mode, which turned out to be its own small puzzle:
- In meaning mode, input is lowercased and a leading article (
to,the,a,an) is stripped, so "to see" and "see" both land. A word that appears as one comma-separated piece of a longer gloss also counts. - In reading mode, I accept the kanji itself (your IME will happily produce it), any on or kun reading, and I normalize katakana to hiragana by subtracting
0x60from each code point so the two scripts compare equal.
Deliberately no build step
It would be easy to reach for a framework. I went the other way: the trainer is plain HTML, CSS, and JavaScript with no build step, served from a static directory. The build script even emits the dataset twice, once as JSON and once as a tiny window.KANJI_DATA = [...] shim, so the page also works when opened straight off disk where fetch() would be blocked by CORS.
That choice was not about saving effort. The genuinely interesting part of this project was always the data: getting the ordering right, resolving the component graph without dangling references, and constraining examples to what you already know. Once the dataset is correct, the interface that consumes it is small, and pouring the attention into the data instead of the tooling kept the whole thing easy to reason about and easy to keep alive across years of personal study.
What I took away
- When your items genuinely depend on each other, model the structure explicitly; do not flatten everything into a frequency list and hope.
- Keep "what is this made of" and "when do I teach it" as separate questions. A component graph and a human-built curriculum each answered one of them better than a single sort answered both.
- Gate unlocking on exposure, not mastery, and let a spaced-repetition scheduler own recall. Tangling the two stalls the learner.
- Track distinct skills in separate buckets so progress in one cannot mask a gap in another.
- Put the effort into the data and let a small static page consume it.