Neurotype
A typing practice game whose passages are written on the fly by a language model running in your browser — no API, no key, and no text leaving the tab.
- Section
- games
- Year
- 2026
- Role
- Solo project
- Stack
- TypeScript, Vite, transformers.js, WebGPU, Web Workers
Summary
A typing practice game whose passages are written on the fly by a language model running in your browser — Qwen2.5-0.5B through transformers.js on WebGPU. No API, no key, no text leaving the tab. The weights download once and every passage after that is generated on the player’s own hardware.
Story
A fixed corpus stops measuring you after a week: the muscle memory attaches to the specific paragraphs and the number goes up without your hands getting better. Endless fresh text fixes that, and doing it by API means a key, a server to hide it behind, and a bill that scales with practice. Running the model locally instead imposed three constraints that shaped most of the code. A 0.5B model is small enough to ship and too small to trust, so nothing reaches the screen unsanitized — a character you physically cannot type makes the race unwinnable. Nobody waits on inference, so generation happens in a worker into a two-deep queue filled while you type the previous passage. And the 483MB download is opt-in, with a bundled seed corpus that makes the game fully playable without it. Getting usable prose out turned out to be a filtering problem, not a prompting one: listing style rules just gets the request paraphrased back, while showing it two passages from the seed corpus works.
Lessons
- Treat small-model output as hostile. Type-token ratio under 0.55, a repeated adjacent word pair over 2, or similarity to the last four passages all reject it.
- Rejection is the normal case, not the error case. A sanitizer that reports every rejection as a fault declares itself broken while working perfectly — only six consecutive failures count.
- A base path is not a route. The worker and the 23MB ONNX WASM binary are
fetched by URL at runtime and break the same silent way;
vercel.jsonmaps the prefix back to root.