Many typing games set the speed for you. Words fall at a fixed rate, so someone learning at 20 WPM gets buried and someone at 150 WPM gets bored. We wanted one arcade game that's the same challenge for both, where the only way to do better is to type better.
Getting there took four things: a pace taken from the player's own history, measuring everything in characters, a test that plays the game at different speeds, and a renderer light enough for an old laptop. Here's each one, including the bug that taught us the second.
Disclosure: the game is Outpace, on TypingPractice.ai, which is our site. This post was written with help from AI, and we checked every number against our code and test output.
The game in one paragraph
You type a line of text, and every right key moves a comet one character on. Night follows through the same text at a set pace. A wrong key doesn't move you: the caret waits for the right one. If the night reaches you, you lose one of three lights and it falls back a little. A run is ten "skies", each a bit faster than the last, and it ends when your lights are gone or you clear sky 10.
1. The pace comes from your own history
The night's speed is a share of your usual speed: the median WPM of your last 10 rounds. Lessons and key drills are left out, because a few letters typed over and over aren't a fair measure of speed. The median means one distracted round doesn't swing it.
// Sky 1 is 80% of your usual speed; each sky after adds 6%. const nightPace = (sky: number) => 0.8 + 0.06 * (sky - 1); // sky 1: 0.80, sky 5: 1.04, sky 10: 1.34 So the first skies are a little slower than you usually type, sky 5 is about level with it, and sky 10 needs 34% more. A new player with no rounds yet types a 45-character warm-up first, which sets a starting number.
It also means the game gets harder as you get faster. You can't grind it: the only way further is to type better than you usually do.
2. Measure the world in characters
Positions in Outpace are characters of the text, spaces included, the same unit WPM is built on (a "word" is five keystrokes). The comet is how far you've typed correctly, and the night is how far it has reached.
The screen shows the gap between them in seconds at the night's pace. So the picture looks the same at every speed: the comet sits in a fixed spot, and the night is your lead in seconds behind it.
Each sky is 18 seconds of text at its night's pace. For a 20 WPM typist, sky 1 is 24 characters; at 150 WPM it's 180. A run lasts about three minutes at most, at any speed.
The lead has limits too. You start 4 seconds ahead, each sky's gate pushes the night back half a second, and the lead is capped at 5 seconds. You can't bank time in an easy sky and coast through a hard one.
3. The bug: penalties in seconds
Our first version charged a slip in seconds of lead. That sounds neutral, but it isn't. At the same accuracy, a fast typist makes more slips per second, because they press more keys per second. At 96% accuracy, a 120 WPM typist makes about 0.4 slips a second and a 40 WPM typist about 0.13. Same accuracy, three times the penalty.
The fix was to charge in characters:
// Before: a cost in seconds of lead, the same for everyone. night += SLIP_COST_SECONDS * nightCharsPerSecond; // After: a slip lets the night move 2 characters closer... night += 2; // ...and a clean word with this sky's focus key or letter pair pushes it back 1. night -= 1; Now a slip costs the same share of your pace at any speed. We simulated the current rules with the slip cost swapped back to half a second. The simulated typist types at exactly their usual speed and slips every 50th key (98% accuracy) or every 25th (96%). Skies cleared, out of 10:
| Accuracy | Slip costs | 20 WPM | 40 | 60 | 120 | 150 |
|---|---|---|---|---|---|---|
| 98% | 0.5 seconds | 7 | 7 | 7 | 6 | 5 |
| 98% | 2 characters | 7 | 7 | 7 | 7 | 7 |
| 96% | 0.5 seconds | 6 | 6 | 5 | 4 | 3 |
| 96% | 2 characters | 5 | 6 | 6 | 6 | 6 |
With a cost in time, the fastest typist got half as far as the slowest for the same accuracy. In characters, everyone ends within a sky of each other.
The reward follows the same rule. The clean-word bonus is in characters too, and the focus comes from the player's profile: one of their weak keys or slow letter pairs, taking turns from sky to sky. Words that contain it glow, so the bonus lands where practice helps most.
4. Make fairness a test
That table comes from a small harness in our unit tests. The game's rules are plain functions with no DOM: right(), slip(), wordDone() and tick(). A scripted typist calls them with a fake clock: keys at an even rhythm, a slip every Nth key, a frame every 16 ms. A whole run takes milliseconds, so we can play hundreds.
it('is the same run at 20 and at 150 wpm, for the same pace and accuracy', () => { for (const pace of [0.85, 1, 1.15]) { for (const slipEvery of [0, 50, 20]) { const slow = play(20, pace, slipEvery); const fast = play(150, pace, slipEvery); expect(Math.abs(slow.cleared - fast.cleared)).toBeLessThanOrEqual(1); } } }); Next to it are tests that typing 12% faster than usual clears more skies, a slip every 15 keys clears fewer, typing 40% faster clears all ten, and a run lasts about three minutes at most. If someone tunes a number later and breaks fairness, a test fails.
5. Don't let the clock cheat
A timing game has more clocks than you'd think, and each one can be unfair:
- Long frames. A frame longer than 250 ms (a stall, a background tab) moves the night at most 250 ms.
- Leaving. The night pauses when the tab is hidden or the window loses focus, and on Esc.
- Pauses. Paused time is taken off the round's clock, so it doesn't lower your WPM.
- Getting caught. If the night catches you while you've stopped typing, the round ends at your last key. The seconds you sat there aren't typing, so they don't count.
6. Light enough for an old laptop
We wanted Outpace to run on school Chromebooks and old laptops, so it uses no game engine. The sky is a plain 2D canvas:
- Glows (the comet, its trail, sparks, stars) are small sprites drawn once at start and stamped with
drawImage. There's noshadowBlurand no filters, which are what usually make a canvas slow. - The sky gradient is cached until the sky changes. Each frame is a few fills: three hill outlines, the night, a gate ring and a fixed pool of particles.
- The canvas is opaque (
{ alpha: false }), its pixel ratio is capped at 2, and on big screens it's capped lower (about 2.4 million pixels in all). - If frames run slow during a run, quality steps down: fewer particles first, then no aurora and 30 frames a second. It never steps back up mid-run.
- When nothing moves (before a run, paused, after it), it paints 15 frames a second. When the tab is hidden or the game is scrolled off-screen, the loop stops.
Typing runs outside React. The line is a row of spans in a monospace font, and each word's left edge is measured once, when it's added. A letter's position is that edge plus its index times the letter width, so nothing reads layout while you type. The caret and the line move with CSS transforms.
In headless Chrome on a Mac, a run used about 5% of one CPU core on the main thread, at 60 frames a second. With Chrome's CPU throttled 4x, it used about 18% and still held 60, without stepping quality down. (That's main-thread time; GPU work isn't in it.)
One bug is worth passing on. We measured the line while it was hidden with display: none. Every width came back 0, and after a restart the words sat off-screen. Now a ResizeObserver measures again when the line is shown, and measuring is skipped while it has no width.
7. Don't move words people are about to read
Our second game, Word garden, turns the words you miss or slow down on into plants. A clean word, typed no more than 10% slower than your usual pace, grows a stage. A plant grows at most two stages a day and blooms at four, so every word takes at least two days. That spacing is on purpose: short sessions, no grind. The plants are SVG, picked from a hash of the word so "which" always grows the same flower. All motion is CSS keyframes, so there's no animation loop at all.
Its first version showed the current word large in the middle and the next two small beside it. Every new word changed size and place as it came up. In testing, that was distracting and hard to read ahead, and the timings agreed: one clean word took 329 ms a key, against a usual 161.
Typists read a word or two ahead, and moving words breaks that. Word garden now types on the same steady line as Outpace, with every word the same size. A word that needs another try joins the end of the line, so the words ahead never change. Our browser test now checks both: the line ahead never changes, and every word on it is the same size.
What we'd tell anyone building a typing game
- Set the pace from the player's own history (a median), not a fixed speed.
- Count costs and rewards in characters, not seconds.
- Put fairness in a test: play your real rules at 20 and at 150 WPM.
- Never move or resize words someone is about to read.
You can try Outpace and Word garden at typingpractice.ai/typing-games. They're free and don't need an account. If you've built typing tools, we'd like to hear how you handle pace.
