A Stardew-Valley-flavoured Korean vocabulary game. You plant a Korean word, answer a three-phase quiz as the crop grows, harvest it for Gold, and spend the Gold on new vocabulary packs, farm plots and cosmetics. 25 levels, 1,500 words, TOPIK 1–3 range.
Built with Phaser 3 and vanilla JS — no build step, no framework, no bundler. All
sprites are generated procedurally from character-matrix + palette definitions in
game.js, so the whole game ships as four static files.
Any static file server from the repo root works; the game fetches levels.json and
facts.json over HTTP, so file:// will not do.
npx serve -l 8742 .Then open http://localhost:8742/index.html.
A PyWebView wrapper hosts the same HTML in a native window and swaps localStorage
for a real save file (save_data.json).
python -m pip install pywebview
python main.pyOr double-click run.bat. Note run.bat hardcodes a Python path and falls back to
python on PATH.
An Express dashboard for editing the curriculum (levels.json) and reviewing word
origins.
cd admin && npm install && npm start| Key | Action |
|---|---|
WASD / arrows |
Move |
Space / E |
Interact — plant, water, harvest, talk to an NPC |
I / E |
Inventory |
C |
Cooking |
Esc |
Close the top modal |
Keyboard only — there are no touch controls yet, so the farm scene is not playable on a phone.
Scheduling is real SM-2 spaced repetition. The farming loop maps onto it directly: the three-touch plant → water → harvest cycle is the set of learning steps, and harvesting a word graduates it into day-scale review.
new ──plant──> learning ──30s──> ──90s──> review ──1d──> ──3d──> ──8d──> ──20d──> mature
↑ │
└──────── relearning <─────┘ (failed a review)
| Phase | Question | What it is |
|---|---|---|
| 🌱 1 — Plant | Korean shown, pick its meaning from four options | Recognition. Teaches the pairing; you cannot be asked to produce a word you have never seen. |
| 💧 2 — Water | Type the Korean (or pick it by ear, if a Korean voice is installed) | Recall with support |
| 🍎 3 — Harvest | Type the Korean, with origin and pronunciation shown | Production recall → graduates the word |
Once graduated, a word resurfaces on its own schedule. Open the farm and words that have come due are already standing there as ripe crops: harvesting one is its review. Two plots are always kept free so a review backlog never blocks learning something new.
Answers are graded Again / Hard / Good / Easy, inferred from signals that cannot be gamed — a wrong attempt, a paid hint or a near-miss all mean Hard; a clean fast typed answer means Easy. Failing a review is a lapse: the word loses half its interval, its ease drops, and it goes back through relearning.
Two properties worth knowing:
- An interval can only be earned by waiting. Answering a word ahead of its due date counts as a rep and reschedules it, but does not grow the interval — you cannot reach "mature" by drilling one word twenty times in a sitting. Reaching 21 days takes roughly four correctly spaced reviews over about 80 real days.
- Easy never skips a learning step. In Anki, Easy is a deliberate "I already knew this"; here it is inferred from answering quickly, which a learner shown the word thirty seconds ago manages from short-term memory. So every word goes through all three touches.
Answers are normalized before comparison — NFC (so a Mac or iOS Korean IME emitting decomposed jamo grades the same as a Windows one), zero-width characters stripped, inner whitespace collapsed, trailing punctuation removed. A one-jamo slip is accepted as "close enough" with a Hard grade rather than thrown away.
A word can declare alternates explicitly:
{ "ko": "아버지", "en": "father", "acceptedAnswers": ["아버님"] }acceptedAnswers (or answersKo / variantsKo) is preferred over inlining alternates as
"가다 / 걷다" in ko — a list states intent, splitting a text field guesses at it. The
delimiter split still works for existing entries.
Progressive hints are priced to keep them a real decision: romanization is free, initial consonants (초성) cost 5 coins, hearing the word costs 10, and the word's origin costs 10. Using any of them caps the grade at Hard.
Knowing 아버지 on sight is not the same skill as typing it from memory, so every word carries an independent interval, ease and due date per modality:
| Modality | Question | Role |
|---|---|---|
type |
Type the Korean for an English word | Primary. Production is the hardest skill, it is what the learning cycle ends on, and it is what graduated and mature measure. |
recognise |
Korean shown, pick the meaning | Teaches first contact; schedules on its own |
listen |
Hear the word, pick the spelling | Only where a Korean voice exists, else falls back to typing |
Answering a four-option recognition question therefore cannot advance the production schedule. Phase 1 seeds both tracks — recognition because that is what was tested, production because the crop timer and phases 2–3 run on it — but only the modality actually answered has its interval moved.
When a word comes due, the review tests whichever modality expired, not always typing. Ties go to production.
| Metric | Means | Used for |
|---|---|---|
| Learned | graduated — through its learning steps at least once | Unlocking zones (Arcade, Fishing, Dungeon, Duel) and quest requirements |
| Mature | review interval ≥ 21 days | The Mastery stat, trophies, the dashboard |
Gating content on maturity would leave a new player staring at locked minigames for a month, so unlocks track learned while mature is the long-haul goal. 📊 Progress in the HUD overflow menu shows what is due, the 7-day review forecast, retention rate and per-level learned-vs-mature bars.
Words are read aloud in ko-KR through the Web Speech API — 🔊 buttons in the vocab
book, fun-fact modal and cat dialog, plus automatic playback when you answer
correctly. A 🐢 button re-reads the word syllable by syllable.
This needs a Korean voice installed on the operating system. Where none is available
every speak control hides itself (.tts-unavailable .tts-only) rather than offering
buttons that do nothing, and the paid audio hint refuses without charging. Playback
can be muted from the 🔊 Audio button in the HUD; the choice persists.
Two generators produce the shipped data. Both are idempotent and safe to re-run.
levels.json ──┬─→ scripts/add_english_labels.js ──→ levels.json (adds nameEn, descriptionEn, categoryEn)
└─→ scripts/build_facts_json.js ──→ facts.json (word origins)
node scripts/add_english_labels.js
node scripts/build_facts_json.js25 levels × 60 words. Each word carries Korean, English, an emoji hint and a category.
English labels sit alongside the Korean rather than replacing it, matching the
name / nameKo convention already used by ITEM_DB, because the Korean topic label
is itself material worth reading.
{
"level": 1,
"name": "일상과 사람",
"nameEn": "Daily Life & People",
"description": "가족, 사람, 일상 동작 및 기본 상태 어휘",
"descriptionEn": "Family, people, everyday actions and basic states",
"words": [
{ "ko": "아버지", "en": "father", "hint": "👨",
"category": "가족과 사람", "categoryEn": "Family & People" }
]
}Generated. Do not hand-edit. Keyed by the Korean headword (all 1,500 are unique, unlike the English glosses). Structured rather than pre-rendered prose, so the UI decides presentation:
{ "부모": { "o": "sino", "h": "父母", "p": [["父","부","father"], ["母","모","mother"]] },
"건강하다": { "o": "sino-verb", "h": "健康", "p": [...], "s": "하다" },
"커피": { "o": "loan", "l": "coffee" },
"아버지": { "o": "native", "note": "respectful term for one's father" } }Romanization, syllable count and 받침 are derived from the Hangul at render time, so they are deliberately not stored.
To add or correct an origin, edit the curated SINO / MIXED / LOANWORDS /
NATIVE_NOTE maps in scripts/build_facts_json.js and re-run it. The admin panel
shows origins read-only and its write endpoints return 409 for this reason.
Coverage is 636 / 1500 (42%), with real hanja for 279 words. The remaining 864 are
classified unknown, and the UI shows pronunciation for them rather than inventing an
origin. That gap is deliberate: the original data asserted "Native Korean (고유어)" for
~1,090 words with no evidence, mislabelling plenty of Sino-Korean vocabulary (건강검진,
환경오염, 기술혁신). Unknown stays unknown. The admin dashboard's Not Curated list is
the backlog.
Origin classes: native, sino, sino-partial (compound built on a known root),
sino-verb, sino-passive, sino-adj, sino-noun, mixed, mixed-loan, loan,
loan-partial, idiom, discourse, unknown.
Roots are chosen by cascade potential rather than alphabetically. Because
sino-partial and loan-partial match curated multi-syllable roots inside compounds,
curating 실업 (失業) also resolves 실업률, 청년실업 and 실업수당. Measuring which roots
appear inside the most still-uncurated words is what moved coverage from 30% to 42% for
about 35 new root entries.
Single-syllable hanja is never inferred: one Hangul reading maps to many characters
(차 = 茶 / 車 / 差 / 次), so a word whose parts cannot be vouched for stays unknown.
Readings are shown with the initial-sound rule (두음법칙) made explicit — 여행 renders
as 旅 (려 → 여) + 行 (행), because printing only the dictionary reading looks like a typo
next to the word on screen, and printing only the surface form hides a rule learners need.
The generator refuses to emit an origin class that renderOrigin() in game.js has no
case for. That switch ends in default: return '', so without the check a new class would
produce entries that are curated but silently display nothing.
Five entries asserted hanja that contradicted their own reading or breakdown:
| Word | Was | Now |
|---|---|---|
| 무료 | 免費, decomposed as 無 + 料 | 無料 |
| 환불 | parts listed in reverse | 還拂 |
| 계좌이체 | 口座 (reads 구좌, the obsolete term) | 計座移替 |
| 병원 | 醫院 (reads 의원 — a clinic, or an assembly member) | 病院 |
| 과일 | 果實 (reads 과실) | native, naturalised — noted as related to 果實 |
game.js 450 KB, ~12.1k lines — engine, 5 Phaser scenes, all game systems
index.html 113 KB — DOM overlays and all CSS inline
levels.json 280 KB — curriculum
facts.json 58 KB — generated word origins, lazy-loaded
main.py PyWebView desktop wrapper + file-based save API
assets/ mirror of the four shipped files (see caveat below)
scripts/ data generators
admin/ Express admin panel + its own test suite
game.js holds five scenes — FarmScene (the hub), ArcadeScene, DungeonScene,
FishingScene, BeeScene — plus the pixel renderer, chiptune synth, day/night and
weather systems, and the economy, quest, inventory and cooking systems.
assets/is a duplicate.main.pyserves from the repo root and copies the four files intoassets/on startup;admin/lib/sync.jswrites both copies. Two sources of truth for the same content — worth collapsing.
State is written to localStorage under hv_save_v2, and additionally to
save_data.json via the PyWebView bridge on desktop. Save format is v6, with the
migration chain in migrateSaveData().
The v4 → v5 step converts the old {p2At, p3At, harvests} SRS records into SM-2
entries. Nobody is reset: harvest count is a usable proxy for how well a word was
known, so it seeds reps and an interval, staggered across days so a veteran save does
not dump hundreds of reviews into one afternoon. Intervals are capped just below the
maturity threshold — maturity has to be earned under the real scheduler rather than
granted retroactively. The migration is idempotent.
The v5 → v6 step nests each schedule under its modality. An old single-track entry lands on the production track, because the three-touch cycle it was earned through ends on typing. Recognition and listening start unseeded rather than inheriting an interval nobody demonstrated — inheriting would claim a skill that was never tested.
Writes are debounced 800 ms because collectSave() serializes the entire state
(currencies, SRS for 1,500 words, plots, inventory, quests, recipes, buffs, seasonal,
leaderboards, ground drops) and persistSave() is called from ~35 places including
every quiz answer. flushSave() writes through immediately and runs on scene
shutdown, page hide and the explicit 💾 Save button.
node scripts/validate_content.js # data invariants — the CI gate, 19 checks
node test_srs_engine.js # SM-2 scheduler + save migration, 93 assertions
node test_r2_shop_vm.js # shop + plot expansion, 65 assertions
node test_m2_harness.js # sprite matrix / palette integrity
cd admin && npm test # admin API, sync, frontend, edge cases — 44 assertionsvalidate_content.js is the one to run before committing data changes. It asserts the
shape of levels.json and facts.json (25 levels, 1500 words, no duplicate Korean
headwords, every word carrying categoryEn, every facts.json entry matching a real
word), that no origin class can render blank, that assets/ has not drifted from the root
copies, and that no Vietnamese has crept back into the shipped source — an invariant
that was established by hand and previously unguarded.
Its three excluded characters are deliberate and documented in the script: ã/õ for the
Portuguese loanword etymologies (pão, sabão) and é for "pet cafés".
test_srs_engine.js runs the scheduler extracted from game.js in a bare vm and
injects now into every call, which is why srsSchedule takes it as a parameter — it
lets months of review history be simulated without touching the clock.
Two known failures, both predating the current work:
test_m2_challenger_cooking.js— 57/61. Asserts exactly 10 cooking recipes; two honey recipes were added later, so the count check and the Master Chef trophy assertions fail.test_m1_challenger_harness.js— 49/49 assertions pass but the process never exits, so it has to be killed. Something ingame.jskeeps the Node event loop alive.
Static hosting. vercel.json sets cleanUrls; the four shipped files live at the
repo root, which is what Vercel serves.
- Curate the remaining 864 word origins. Coverage is 42%; the next lift comes from the same cascade method — measure which roots appear inside the most uncurated compounds and curate those, rather than working through the list in order. The admin dashboard's Not Curated list is the working queue.
- Mobile. Virtual joystick and tap-to-interact for
FarmScene, then PWA install. The review loop suits phones better than desktop — vocabulary study is what people do on a bus. - Cloud save. Losing SRS history when changing machines is a dealbreaker now that the history is the product.
- Daily review cap and a "day rollover" notion. Reviews currently come due at the exact timestamp they were scheduled; a real study tool batches by day boundary and caps how many land at once so a backlog cannot become unmanageable.
- Split
game.jsinto modules behind Vite.FarmScenealone is ~2.4k lines, and ~1.7k lines of cooking/seasonal/leaderboard code sit at top level afterBeeScene. - Wire the checks into GitHub Actions.
scripts/validate_content.jsand the harnesses all pass and exit non-zero on failure, so this is now just a workflow file. - Consider FSRS. SM-2 is a solid baseline, but FSRS fits intervals to the learner's own review log — and the log it needs is now being recorded (see below), so the input is there.
- Stable item IDs.
facts.jsonandsrsDatakey onkoalone, so two entries sharing a spelling would collide. All 1,500 headwords are currently unique, making this latent rather than live — a hash ofko+ part of speech fixes it.
Every graded answer is appended to a bounded log (attemptLog, 500 entries, saved with the
rest of the state): the word, the grade, which question mode produced it, the timestamp, and
the resulting interval and state. SM-2 keeps only the current interval and ease and throws
the history away, but retention analysis and FSRS both need it, and it cannot be
reconstructed after the fact. Nothing depends on it yet beyond the dashboard's rolling
accuracy and 14-day activity strip.
Done in earlier passes: English unification, generated facts.json, Korean TTS, the
SM-2 scheduler with its learning-step reconciliation, recognition and listening question
modes, per-modality scheduling, fuzzy answer matching, the progress dashboard, and the
second origin-curation pass that took coverage from 30% to 42%.