DEV Community

Clackpit
Clackpit

Posted on

I built a real-time typing game with no traditional backend — here's the architecture

I've been mildly obsessed with typing games since I first used TypeRacer in 2009. Last month I finally built my own: Clackpit — a real-time typing game with a daily leaderboard, a "drill your weak words" mode, and a shareable result card. No accounts required.

This post is about the engineering decisions behind it.


The core challenge: making keypresses feel instant

Typing games live or die on latency. If there's any perceptible lag between pressing a key and seeing the result on screen, the whole thing feels broken. The browser event loop is the enemy here.

The naive implementation is to listen to keydown, update some state, then re-render. The problem is that if your render path is slow — even 16ms — it compounds with the browser's own input processing and suddenly the game feels sluggish.

A few things that actually helped:

Don't use a controlled <input> for the active field. I initially used a React-style controlled input where every keypress went through state → re-render → DOM update. The round-trip adds jank. Instead I track position in a ref and only update visual DOM nodes directly when needed, avoiding a full reconcile on every keystroke.

const posRef = useRef(0);
const errorsRef = useRef<Set<number>>(new Set());

function handleKeyDown(e: KeyboardEvent) {
  const ch = e.key;
  if (ch.length !== 1) return; // ignore Shift, Ctrl, etc.

  const expected = passage[posRef.current];
  if (ch === expected && !errorsRef.current.has(posRef.current)) {
    advancePosition();
  } else {
    markError(posRef.current);
  }
}
Enter fullscreen mode Exit fullscreen mode

This keeps state reads synchronous and avoids batched update scheduling entirely for the hot path.


Forced correction: the UX decision I spent the most time on

TypeRacer forces you to backspace over mistakes before you can continue. Monkeytype lets you skip past errors (they count against accuracy, but you keep moving). Which is better?

I went with forced correction, and it was deliberate.

The argument against: it's more frustrating. You're flying through a passage, mistype one letter, and suddenly you're stuck hammering backspace instead of continuing.

The argument for: it actually changes how you type. With skip-past, your fingers learn "type fast, fix it later." With forced correction, you either slow down and type accurately or you pay an immediate penalty. Over time, you develop fewer errors per word, not just faster correction.

For a game that's explicitly about improving typing, forced correction is the right choice. I added a subtle red flash on the error character so it's visually clear why you're blocked — without that feedback, users just hammer keys more frantically.

function markError(pos: number) {
  errorsRef.current.add(pos);
  // Flash the character element red
  const el = charRefs.current[pos];
  el?.classList.add('error-flash');
  setTimeout(() => el?.classList.remove('error-flash'), 150);
}
Enter fullscreen mode Exit fullscreen mode

Scoring in real-time

WPM is a surprisingly contested metric. The "standard" definition is characters typed divided by 5 (one "word" = 5 chars), divided by elapsed minutes:

function calculateWPM(charsTyped: number, elapsedMs: number): number {
  const minutes = elapsedMs / 60_000;
  return Math.round((charsTyped / 5) / minutes);
}
Enter fullscreen mode Exit fullscreen mode

But that raw number is misleading if you typed fast and inaccurately — you'd get credited for errors you then backspaced over. I count only characters that are currently in a "correct" committed state: once you backspace and re-type a character, it only counts once toward WPM.

Accuracy is simpler — total keystrokes versus correct keystrokes at completion:

function calculateAccuracy(totalKeystrokes: number, errorKeystrokes: number): number {
  if (totalKeystrokes === 0) return 100;
  return Math.round(((totalKeystrokes - errorKeystrokes) / totalKeystrokes) * 100);
}
Enter fullscreen mode Exit fullscreen mode

Both are recalculated on every keypress and shown live. Watching your WPM update in real-time as you type is oddly motivating.


The "drill your weak words" loop

This is the feature I was most excited about building, and the one that r/typing has been asking for in various forms for years.

After each race, I track which words you made errors on. Not just "you had 3 errors" — specifically which words those errors occurred in. Then I offer a drill mode that generates a new passage using only those words, repeated and shuffled:

function buildDrillPassage(weakWords: string[]): string {
  if (weakWords.length === 0) return getRandomPassage();

  // Repeat each weak word 2-3x, shuffle, join
  const repeated = weakWords.flatMap(w => Array(3).fill(w));
  const shuffled = repeated.sort(() => Math.random() - 0.5);
  return shuffled.join(' ');
}
Enter fullscreen mode Exit fullscreen mode

The insight: most people have a small set of letter combinations that consistently trip them up — "tion", "the", words with double letters. Random passages rarely hit your specific weak spots enough to train them. Drilling your actual errors does.

The UX is a single button after each race: "Drill your 4 weak words." No friction, no navigation. Most users click it.


The anonymous leaderboard: no login, no friction

I really didn't want to build an auth system. Auth is a solved problem, but it's also friction — every signup form is a place where users bounce.

The leaderboard works like this: pick a handle (stored in localStorage), submit your score. That's it. No email, no password.

// On first visit, prompt for a handle
const handle = localStorage.getItem('clackpit_handle') 
  ?? promptForHandle();

// After each race, submit score
await fetch('/api/scores', {
  method: 'POST',
  body: JSON.stringify({ handle, wpm, accuracy, mode }),
});
Enter fullscreen mode Exit fullscreen mode

Scores are stored in a Cloudflare Worker KV store, keyed by date. The daily leaderboard shows the top 20 WPMs for today. Handles aren't unique — two people can both be "keymaster" — but that's fine. The leaderboard is for fun, not identity.

One thing I added: if you submit a score lower than your existing best for the day, it doesn't overwrite. Your handle shows your peak, not your last run.


The share card

After every race you get a result card showing your WPM, accuracy, and mode. It's copyable text, not an image — partly because generating images serverlessly is a pain, and partly because text shares better across platforms.

The format:

⌨️ Clackpit
97 WPM · 98% accuracy · Standard mode
https://clackpit.launchyard.app
Enter fullscreen mode Exit fullscreen mode

There's a one-click copy button that puts this on the clipboard. Twitter/X, Discord, whatever — it pastes cleanly. The URL is the backlink that makes this worth doing from an SEO perspective, but it also actually sends interested people to a working demo, which is the more important thing.


Wrapping up

Clackpit took about two weeks of evenings to get to something I was happy with. The stack is TypeScript throughout, Cloudflare Workers for the backend, no database beyond KV for leaderboard scores.

The lessons:

  • Input latency is a product problem, not just a performance problem. Test on slow hardware.
  • Forced correction is a meaningful product choice that affects what skill users actually build.
  • Removing signup friction (localStorage handles + anonymous scores) meaningfully increases engagement.

If you want to try it: https://clackpit.launchyard.app

I'd genuinely like feedback — especially from people who type a lot and have opinions about how typing games should work. Drop a comment or find me here.

Top comments (0)