All work

Case study · 2026

Hanzi Rivals

A Chinese vocab game you play with friends, one turn at a time, over text.

Role
Solo: product, design, engineering and art
Platforms
Web app, iPhone app, iMessage game
Built with
TypeScript, React, Express, SQLite, Capacitor, Swift
Status
Live at hanzirivals.com

The turn loop

  1. A duel in progress: Leo's scholar fighter against Ana's fox at 73 and 74 HP, with Light Attack, Heavy Attack, Dragon Strike and Wild Strike to choose from.
    01

    Pick an attack

    Choose how hard to hit, then answer a question to charge it.

  2. Leo charged a 30-power Dragon Strike by writing 机场 (jīchǎng, airport), with buttons to send the turn to Ana by text or link.
    02

    Charge it, send it

    A perfect answer charges full power. The turn goes out as a link or a text.

  3. Ana's side of the duel: 'Leo used Dragon Strike! 30 power incoming', with the blocking rules and a Defend button.
    03

    Your friend blocks

    They defend with the same skill, then counterattack and send it back.

The question

Will two classmates keep sending turns back and forth with their own vocab list?

Flashcards are easy to put off. A friend waiting on your move is much harder to ignore. Hanzi Rivals started as a test of that one idea, so every decision is there to keep the turns coming back.

  • Turns are links. Your friend opens one in a browser: no install, no account, nothing in the way of their first move.
  • Each player brings their own list. Questions come from each player's weakest words, so someone on HSK 1 and a fluent friend on HSK 6 still get a fair match.
  • Waiting is studying. Training between turns earns Focus, a boost for your next attack. That's where most of the study actually happens.
  • Every duel ends with a study list of the words you missed, ready before the rematch.

How a duel works

Every attack is a question. So is every block.

Each player gets eight attacks; the duel ends at 0 HP or when both are spent, and most HP wins. A perfect answer charges full power, a close one 70%, and a miss fizzles. On defense, a perfect block takes only 30% of the hit and earns a Riposte, +20% on the counter.

Attacks and their power
AttackYou have toPower
Light AttackPick the meaning12
Heavy AttackType the pinyin20
Dragon StrikeWrite the characters30
Wild StrikeA random attack at a random difficulty, +35%12–53

Pinyin is graded kindly: each wrong tone costs 20% of the power but never more than half, and spoken third-tone changes count as right, so níhǎo is fine for 你好.

Game modes

Five ways to play

Character Sprint: the word 'weather' with two empty character slots above a grid of nine look-alike characters, a 54-second timer and a score of 2,300.

眼疾手快

Character Sprint

Sixty seconds to hear a word and tap its characters on a board of look-alikes: 已 / 己, 未 / 末, 晴 / 睛. Boards grow from 6 to 12 tiles across five levels. Send the link and friends race the exact same boards.

Character Climb at step 7 of 20 with three hearts left, beside a ladder showing where four players reached.

步步高升

Character Climb

A 20-step climb for a whole group chat, played on your own time with three hearts. You climb beside the ghosts of everyone who went before you, replaying their real climbs on the same clock, and the last climber gets the podium.

  • 对决

    Duels

    Against a friend by link, or against the computer: Rookie, Scholar or Grandmaster, who play by the same rules and favor the attacks they're good at.

  • 每日一练

    Daily

    Eight questions a day, the same for everyone, climbing from HSK 1 to HSK 4. Streaks, a shareable result grid and a weekly board among friends.

  • 词语接龙

    Word chain

    Take turns adding a word that starts where the last one ended: 学生 → 生日 → 日子. Same character or same sound both count.

On iPhone

Played right inside Messages

Hanzi Rivals is also an iMessage app. Play your turn and it lands in the chat as a bubble with both fighters, their HP and what's coming next. Each game keeps a single bubble that updates every turn.

In a group chat, a Climb's bubble shows everyone's ladder and how many are left to play, then turns into the podium. The extension is a Swift MessagesViewController that loads the same game screens from the server and shares the iPhone app's sign-in.

A Hanzi Rivals message bubble from iMessage: Leo at 73 HP versus Ana at 74 HP for the list Lesson 12 · Travel.
A Character Climb bubble from an iMessage group chat: Sam reached step 17 of 20, Ana 14 and Mei 9.

Engineering notes

The parts that were fun to solve

  1. 01

    Forgiving handwriting, without OCR

    Each stroke you draw is matched to the closest stroke still to be written, in any order or direction, against the character's stroke data. A bent stroke can be two lines, a small or off-center character still counts, and after three misses the next stroke is drawn for you. Writing in the standard order earns a bonus rather than being required.

  2. 02

    Look-alike characters from geometry

    Sprint boards need convincing decoys. Each character's stroke skeleton is drawn on a 32×32 grid and compared with others by chamfer distance: how far each line of one sits from the nearest line of the other. A short hand-picked list adds classic pairs the shapes miss, like 买 / 卖.

  3. 03

    The server keeps score

    A sprint's 60 boards and a climb's 20 steps are generated once on the server and stored. Every reported run is re-scored from its taps with the same rules the device used, and climbs are checked step by step: in order, no more wrong taps than hearts, no step over a minute.

  4. 04

    One client, three surfaces

    The same React client runs on the web, inside a Capacitor iPhone app, and in a Swift iMessage extension. The web signs in with a cookie and the app with a bearer token, and the app and its iMessage extension share one sign-in through an App Group.

  5. 05

    A dictionary that searches as you type

    All of CC-CEDICT, about 124,000 entries, searchable in pinyin with or without tones (even half-typed), English or characters. Results rank by HSK level and real-world word frequency, and crude senses are filtered out by default.

  6. 06

    Every sound is synthesized

    There are no audio files. A guzheng plays shuffled phrases on the D pentatonic scale, the brush-on-paper sound follows your pen speed, and a gong lands for a Dragon Strike, all built with the Web Audio API.

  7. 07

    XP as an idempotent ledger

    Experience is stored as a ledger keyed by what earned it, so a retried request can never pay out twice, and the server refuses fighter colors that haven't been unlocked yet.

  8. 08

    A marketing site from the game's own code

    hanzirivals.com is prerendered to static HTML from React components. Its try-a-round demo imports the real game rules and pinyin grader, so the website can't drift from the game.

Stack

What it runs on

Client
TypeScript, React 19, Vite
Server
Node, Express 5, node:sqlite (Node's built-in SQLite)
iOS
Capacitor 8, Swift with the Messages framework
Data
CC-CEDICT, HSK 1–6 word lists, wordfreq, Hanzi Writer stroke data, pinyin-pro
Hosting
Docker on Fly.io: one always-on machine, SQLite on a volume
Quality
114 server tests with node:test, strict TypeScript throughout

Try a round. It takes about 30 seconds.

No sign-up, nothing to install. Just you, a fox named Ana, and a few words of Chinese.