A tiny Game Boy Color ROM where a cat guides you through Japan — one kana at a time.
You wander through six little locations — a train station, a street, a shrine, a restaurant, a convenience store, and a park — chatting your way through short Japanese scenes and answering kana quizzes to keep going, to a little pentatonic soundtrack. Miss too many and it's game over (with a kana password to pick up where you left off). Nail them and watch your score climb.
The whole thing is hand-assembled from TypeScript. No engine, no SDK — just raw opcodes wired into a 32 KB ROM.
▶ Play it in your browser, on a computer (keyboard or gamepad) or a phone (touch controls).
| Scene | Name | Vibe |
|---|---|---|
| えき | Station | Arriving somewhere new |
| みち | Street | Wandering around town |
| じんじゃ | Shrine | Praying, fortunes, a cat |
| レストラン | Restaurant | Ordering food (poorly) |
| コンビニ | Convenience store | Late-night snack run |
| こうえん | Park | A quiet ending |
- Pick はじめる on the title screen and arrive at the station
- Chat with the people you meet (in Japanese!): good replies earn points and hearts, rude ones cost a heart. Hold B to make the text appear faster
- After each conversation, a short kana quiz asks about phrases you just read: pick the missing kana with the d-pad
- Some replies have a kana missing (はい みえ□す!): pick one to say it and fill in the blank
- Gave a bad answer? Get the next kana right to win the heart back
- Each location ends with a review round, closing with a word to write in katakana; three first tries in a row pay a streak bonus
- Between locations, the kana chart (かなの きろく) shows every kana you've answered right, the ones you've missed in red, and a password (あいことば) for the next location
- Clear all six locations, then beat the clock in a speed round, which asks about your missed kana first, before the ending
From the title menu, つづき takes a password (game over gives you one too), and れんしゅう practices the kana you've missed, five at a time, with nothing at stake.
See the gallery; regenerate it with npm run screenshots.
Needs Node.js 25+ — older versions (e.g. 18) won't work with the current toolchain.
npm install
npm run buildOut comes build/kananeko.gb — load it in any Game Boy emulator and go.
A symbol file (build/kananeko.sym, BGB/no$gmb format) is also produced for debugging.
npm test # run all tests
npm run test:coverage # with coverage
npm run check # typecheck + lint + format check + tests (what CI runs)test/asm/— opcode encodings, assembler validation, and macros (run in the emulator)test/content.test.ts— static checks over every scene: dialogue graph, text widths, kana questions, ROM encoding round-tripstest/play/andtest/scenes/— the real ROM driven by joypad input in serverboy, a headless Game Boy emulator
Emulator tests start from checkpoints (the start of each scene and kana round) recorded from one continuous playthrough. Checkpoints are cached in node_modules/.cache/kananeko/, keyed by the ROM and the test driver source, so only the first run after a change pays for the playthrough.
Each scene's test file lists the dialogue paths worth exercising as detours from the good path:
describeScene(0, [
{ name: 'tired clerk', detours: { 1: 2 } }, // at node 1, pick choice 2
]);The GitHub Pages site runs the ROM in binjgb, a Game Boy emulator compiled to WebAssembly (vendored in site/vendor/), with a small player of our own (site/src/): touch controls, keyboard and gamepad input, and sound.
npm run site # build/site: the page, the player, binjgb, and the ROM built from source
npm run site:serve # serve it at http://localhost:4173
npm run test:site # build it and test it in Chrome and in WebKit on an iPhone-sized screen
SITE_URL=https://lixo.org/kananeko/ npx playwright test # test the deployed siteThe browser tests (site-tests/) use Playwright; install its browsers once with npx playwright install chromium webkit. Every push to main rebuilds, tests, and publishes the site (.github/workflows/pages.yml).
src/ the ROM, built in pure TypeScript (no Node or DOM APIs)
asm/ assembler, opcodes, macros, RAM allocator, hardware defs
game/ engines (dialogue, kana, HUD), fonts, screen helpers, RAM map (ram.ts)
scenes/ station, street, shrine, restaurant, conbini, park
test/ unit, integration, and emulator-driven tests
scripts/ Node scripts: the ROM build (build.ts), screenshots, the site build
site/ the web player: page, styles, player source, vendored binjgb
site-tests/ browser tests for the site (Playwright)
build/ ROM and site output
Each top-level directory has its own tsconfig.json (sharing tsconfig.base.json) with only the globals it runs with; npm run typecheck checks them all incrementally.
Game code is TypeScript that returns lists of instructions. A routine reads like the assembly it becomes:
import { ifThen } from '../asm/flow';
import { decByte, storeByte } from '../asm/macros';
import { label, ld, or, ret } from '../asm/sm83';
import { type Op, routine } from '../asm/types';
import { MEM } from './ram';
import { KanaState } from './state';
// A routine says which registers it changes; the assembler checks the code and its callers
export const LOSE_LIFE = routine('lose_life', { clobbers: ['a'] });
function buildLoseLife(): Op[] {
return [
label(LOSE_LIFE),
ld('a', [MEM.KANA_LIVES]), // brackets are memory, as in LD A,(nn)
or('a'),
ret('z'),
...decByte(MEM.KANA_LIVES), // A = the lives left, Z if none
...ifThen('z', [...storeByte(MEM.KANA_STATE, KanaState.IDLE)]),
ret(),
];
}-
Instructions (
src/asm/sm83.ts): one function per mnemonic. Forms the CPU doesn't have, likeld('b', [MEM.X]), are type errors, and immediates are range-checked. -
Variables (
src/game/ram.ts): declared asbyte(),byteOf(SomeEnum),word()orbuffer(n), and laid out by the allocator. AbyteOfbyte only takes that enum's values. A word has.loand.hi, and a buffer has.addressand a bounds-checked.at(i). -
Labels are objects, so a misspelt one is a compile error. Where they're declared shows how far they reach:
- module constants (
KANA_DRAW_HUD, or inlabels.tswhen another module calls them) are routines and data; const wrongAnswer = new Label('kana_wrong')inside a builder is a branch within its routines, like a state's handler or a shared exit;- plain branches and loops use
src/asm/flow.ts, which makes the labels for you:ifThenandifElsefor short branches,block((done) => …)to jump forward to a common exit, andrepeat,doWhileandloop((again, done) => …)for loops. Their labels are named after the label before them in the.symfile (kana_update.3).
- module constants (
-
Branching on a variable's value:
dispatch(MEM.KANA_STATE, KanaState, { IDLE: 'ret', AWAITING: 'continue', … })needs a case for every value of the variable's enum: a label to jump to,'ret', or'continue'.compare,ifEqualsandifNotEqualsonly take values of the variable's own kind. -
Common sequences:
incByte,decByte,copyByte,copyWord,tableEntry(table, stride)andaddAToHlinsrc/asm/macros.ts;pressed,held,ifPressedandifHeldfor the buttons insrc/game/joypad.ts. -
Routines are labels with a register contract:
routine('name', { in: ['a', 'hl'], out: ['hl'], clobbers: ['bc'] }). A routine that takes arguments (in) is called withinvoke, which needs one for each register:...invoke(CAT_PLACE, { b: spot.x, c: spot.y }) // LD B,x / LD C,y / CALL cat_place ...invoke(VRAM_PUT, { a: 'a', hl: 'hl' }) // already in A and HL: just CALL
A plain
call()to it, or a missing or extra argument, is a type error. The build follows each routine through its jumps, calls and PUSH/POP pairs, and fails in these cases:- the routine reads a register before setting it that isn't in its
in, or takes one it never reads; - the routine changes a register its contract doesn't allow;
- a caller still needs a register the routine clobbers, including one a later call takes as an argument;
- the VBlank handler doesn't restore what it uses.
It also works out the deepest the stack can go.
npm run contractssuggests contracts from what the code does. - the routine reads a register before setting it that isn't in its
-
Modules (
src/asm/program.ts): each engine exports agameModule({ name, init, code, data }).main.tsonly lists the modules and says what the game does, andlink()lays out the boot code, the inits, the main program, then every module's code and data. -
Records (
src/asm/struct.ts): a layout likestruct('scene entry', { dialogue: word(), flag: byte() })gives the field offsets the assembly reads, the encoder for the ROM data and a decoder for tests, from one declaration. -
Jumps:
jpbecomes a 2-byteJRwherever it reaches, and loads from HRAM becomeLDH.
- The ROM is assembled entirely from TypeScript — a custom assembler in
src/asmemits raw Game Boy machine code. - Hiragana, katakana, and latin glyphs are all baked into the ROM as tile data.
- Gameplay tests boot the actual ROM in a headless emulator and drive it with simulated joypad input.
- The final ROM is exactly 32 KB — one bank, no mapper.