Skip to main content

Command Palette

Search for a command to run...

From Monkeytype to My CLI: Building a Go-Based Typing Test with TUI and ZType Mode

Updated
8 min read

Fair warning: this post is part "here's a thing I built", part "here's what I learned building it", and part "you should probably just try it." So let's go.

Live demo: chuma-beep.github.io/typist

Source: github.com/chuma-beep/typist


How this started

I use Monkeytype. It's good. But every time I opened a browser tab to practice typing I felt this subtle friction — like I was context-switching away from where I actually work. I live in the terminal. My editor is in the terminal. My git workflow is in the terminal. Why am I going to a website to practice the thing I do in the terminal?

Also, the code mode on most typing tests is... not great. Random snippets, weird formatting, stuff that doesn't look like anything you'd actually write. I wanted to practice the patterns my fingers actually need to know: function signatures, match arms, closures, error handling.

So I started building typist as a side project — primarily to learn Go properly, secondarily because I genuinely wanted to use the thing. A few months later it has a full TUI, a web version deployed on GitHub Pages, a profile dashboard, and somehow a ZType-style falling words game mode. Classic scope creep, honestly. No regrets.


What it is

typist is a typing test with four modes:

words — 30 common English words. Quick and clean. Good for your raw baseline.

time — Countdown from 15 seconds to whatever you want (you can set a custom duration up to an hour, which is unhinged but I built it anyway). The timer doesn't start until you press your first key — a small thing that annoyed me about other tests.

quote — Random literary excerpts. Fitzgerald, Tolstoy, Orwell. Good for practicing rhythm with actual punctuation.

code — Real, hand-written snippets in Go, JavaScript, Python, and Rust. Tab and Enter are live keystrokes. Syntax highlighting shows up as you type. This is the one I actually built the whole thing for.

There's also a game mode now (more on that later) which was absolutely not in the original plan.


The stack

The TUI is built with Bubble Tea and Lipgloss — Charm's Go libraries for terminal apps. If you haven't tried Bubble Tea before, it's genuinely a pleasure to work with. It's Elm-style: your entire app is a Model, updates return a new Model instead of mutating state, views are pure functions of state. Once it clicks it makes TUI code feel a lot more manageable than I expected.

Syntax highlighting in the TUI goes through Chroma v2 — at load time it tokenizes the code snippet and builds a map from character position to lipgloss style. While you're typing, the renderer just looks up the style for each character position. Typed characters get dimmed regardless of their syntax colour so you can see where you've been without the highlighting fighting for attention. Wrong characters get a red background.

The web version is a single HTML file — no framework, no build step, no server. It runs entirely in the browser, stores scores in localStorage, and deploys on GitHub Pages. I ported the tokenizer to JavaScript and wired Chart.js for the WPM graph. Three themes (Catppuccin Mocha, Catppuccin Latte, Gruvbox) work on both the TUI and web via the same colour decisions.


A few things that took longer than expected

Getting WPM right

WPM sounds simple: correctly typed characters divided by 5, divided by minutes elapsed. And it mostly is. But there are two things I got wrong first.

Raw vs net WPM. If you type teh, backspace, and fix it — did you make a mistake? The naive implementation says no, because the mistake is gone from the input buffer. typist tracks rawCharsTyped (every keypress including backspace) and shows raw WPM alongside net WPM in the results. The gap between them tells you how much your corrections are costing you.

Early sample inflation. I sample WPM every second for the results graph. The first couple of seconds are garbage — if you've typed 8 characters in 0.8 seconds the formula spits out 120 WPM even if you're a 65 WPM typist. The results chart now skips the first two samples. Not a clever solution, but it works.

Getting the WPM graph right

This took three full rewrites.

The first attempt was a vertical bar chart. Looked fine until I realized it was stretching every test to fill exactly 48 columns regardless of how long the test was. A 15-second test got its 13 samples padded out to 48, creating a staircase of flat blocks. Not a chart, just noise.

Second attempt: a sparkline using ▁▂▃▄▅▆▇█ — one Unicode block character per second, no resampling. Better! But I scaled it from 0 as baseline. If you type 68–75 WPM, every character maps to 90–100% of height. Flat line again, for different reasons.

Current version: 5-row bar chart, scaled from the session's actual min→max range with a 15 WPM enforced minimum spread. So if you type 68–75 WPM, that 7 WPM of variation fills the full chart height. Width uses your terminal width minus margins. Peak sample highlighted in yellow. This is the one I'm happy with.

The game mode renderer

The game mode (falling words, lock on target, type to destroy) has a constraint that Bubble Tea doesn't naturally solve: you need to place things at specific (x, y) coordinates. Bubble Tea renders line by line — it doesn't have a "put this text at position 15, 8" concept.

Solution: build a 70×22 rune grid, where each cell carries both a character and a lipgloss style. Place every game element (stars, enemy ships, projectiles, the player ship) into the grid at their coordinates, then render the whole thing as a string at the end of each frame.

type cell struct {
    ch    rune
    style lipgloss.Style
}

grid := make([][]cell, gH)
// ... fill grid by coordinate ...
for y, row := range grid {
    for _, c := range row {
        sb.WriteString(c.style.Render(string(c.ch)))
    }
}

It works well. Ticks every 120ms. Each enemy gets a 2-row ASCII ship above its word, engine exhaust below. Boss enemies get a wider 4-row hull. Projectiles are dots that float toward the locked target with floating-point coordinates.


The profile dashboard

Ctrl+P from anywhere opens a personal stats screen. This was one of those "one afternoon" features that turned into way more than that.

It shows: avg WPM, avg accuracy, all-time best, total sessions, estimated time spent, current daily streak — then a WPM sparkline of your last 30 sessions, a personal bests table grouped by mode/language/duration, a 70-day GitHub-style activity heatmap, and a weekly pattern bar (which days of the week you type most).

The activity heatmap specifically was satisfying to build. Each of the 70 squares represents one calendar day. Colour scales from dim gray through teal to yellow based on how many sessions you had that day. Empty days are a muted . It gives you the consistency picture at a glance — and it's motivating to see those squares fill in.


How to get it

go install github.com/chuma-beep/typist@latest

Then just run:

typist

If you want the web UI served locally:

typist --web

Or skip the install entirely and use it in the browser at chuma-beep.github.io/typist. All three themes, all modes, full WPM graph and heatmap. No account, no data leaves your browser.


Shortcuts worth knowing

Key What it does
Ctrl+T Cycle themes (mocha → latte → gruvbox)
Ctrl+P Open profile dashboard
Ctrl+G Jump to main menu
Ctrl+R Restart with new text
Ctrl+B Blind mode (dots instead of characters)
Ctrl+F Focus mode (hides stats while typing)
Esc Quit confirmation

What I'd do differently

Honestly, not much on the architecture side. One file per concern worked well — adding the game mode touched model.go in five places and otherwise lived entirely in game.go. The Bubble Tea model-as-pure-value approach made refactoring surprisingly painless.

The main thing I'd change is starting the web version earlier. I treated it as an afterthought for a long time and then had to re-implement a bunch of logic in JavaScript. If I'd treated both as first-class from the start the code would be cleaner.


What's next

The web game mode — same falling words mechanic as the TUI, but with CSS animations and a proper canvas renderer for the ships. The TUI version proved out all the game mechanics, now it's just a matter of making it look good in a browser.

If you build something with Bubble Tea or Chroma after reading this, or if you find a bug (or an embarrassing edge case in the WPM calculation), drop a comment or open an issue. Always interested to hear what breaks.