Skip to content

Repository files navigation

🌾 Hangeul Valley

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.


Quick start

Browser

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.

Desktop (Windows)

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.py

Or double-click run.bat. Note run.bat hardcodes a Python path and falls back to python on PATH.

Admin panel

An Express dashboard for editing the curriculum (levels.json) and reviewing word origins.

cd admin && npm install && npm start

Controls

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.


How the learning loop works

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)

Learning a new word — three touches

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

Reviewing — one touch, on schedule

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.

Each skill schedules separately

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.

Two progress metrics, deliberately

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.


Korean pronunciation

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.


Data pipeline

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.js

levels.json — the curriculum

25 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" }
  ]
}

facts.json — word origins

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.

How curation is targeted

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.

Corrections to the original data

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 果實

Project layout

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.py serves from the repo root and copies the four files into assets/ on startup; admin/lib/sync.js writes both copies. Two sources of truth for the same content — worth collapsing.


Saves

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.


Tests

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 assertions

validate_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 in game.js keeps the Node event loop alive.

Deployment

Static hosting. vercel.json sets cleanUrls; the four shipped files live at the repo root, which is what Vercel serves.


Roadmap

  1. 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.
  2. 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.
  3. Cloud save. Losing SRS history when changing machines is a dealbreaker now that the history is the product.
  4. 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.
  5. Split game.js into modules behind Vite. FarmScene alone is ~2.4k lines, and ~1.7k lines of cooking/seasonal/leaderboard code sit at top level after BeeScene.
  6. Wire the checks into GitHub Actions. scripts/validate_content.js and the harnesses all pass and exit non-zero on failure, so this is now just a workflow file.
  7. 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.
  8. Stable item IDs. facts.json and srsData key on ko alone, so two entries sharing a spelling would collide. All 1,500 headwords are currently unique, making this latent rather than live — a hash of ko + part of speech fixes it.

Review history

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%.

Releases

Packages

Contributors

Languages