Skip to content
Keythm

Keythm

A browser typing test that plays a separate mechanical sound for each of ~80 keys from one 1.9MB audio sprite, with 12 anti-cheat checks.

Type

Web App

Role

Full-stack Developer & Designer

Built

Q2 2026

Updated

Q4 2026

Source

GitHub

Tech Stack

Next.js
React
TypeScript
Tailwind CSS
Drizzle ORM
Motion.dev
Shadcn UI
web-audio-api
serwist
Zod
recharts

TL;DR: Keythm is a typing test with per-key mechanical keyboard sounds, four test modes and a results screen with a WPM-over-time chart. It is built with Next.js, React, TypeScript, Tailwind CSS, the Web Audio API and Serwist, and works offline as a PWA. The hard parts were slicing ~80 key samples out of a single 1.9MB OGG sprite on every keystroke, and catching scripted typing with 12 anti-cheat checks on each result.

01

Q should not sound like Enter

I type on a mechanical keyboard every day. Open a typing test in the browser and the keypress that clicks on my desk makes no sound on the page, and at the end I get a WPM number with no breakdown of what happened during the test. That disconnect kept bothering me.

Before writing any UI, I set the one constraint every later technical decision follows from: per-key audio. Pressing Q has to sound different from pressing Enter, and the sound has to arrive fast enough that you never notice a delay. Nobody sees a 50ms delay on a hover state, but 50ms between a keypress and its sound feels broken.

02

One hook runs four modes in the browser

Keythm is a Next.js app where everything that matters runs client-side. A single hook, useTypingTest, owns the full test lifecycle across four modes:

  • Timed: 15 to 120 seconds.
  • Word count: 10 to 100 words.
  • Quotes: curated quotes, filtered by length.
  • Zen: unlimited, Shift+Enter to end.

Every second, the engine snapshots WPM, raw WPM and error count. Those snapshots feed three things: the live stats overlay while you type, the Recharts WPM-over-time chart on the results screen, and the consistency score. Consistency is 100 minus the coefficient of variation of the per-second WPMs (the standard deviation divided by the mean), so a steady rhythm scores high and erratic bursts score low.

State lives in localStorage under a tc- prefix: settings, personal bests, preferences. Serwist precaches the app, so after the first visit it works with no connection. Committing to offline early meant I stopped reaching for server state by default. The only server-side state is a visit counter in LibSQL through Drizzle, so if the server is unreachable you lose that count and nothing else.

Two smaller pieces handle first paint and the frame budget:

  • Theme before hydration. A small inline script in <head> reads the accent color from localStorage before the first paint, so the page never flashes the default color and then swaps.
  • Keyboard gating. The on-screen keyboard is a full QWERTY layout that animates every key press. An IntersectionObserver at a 10% threshold detaches its physical key listeners when it scrolls out of view, so it does no work while you can't see it.
03

One 1.9MB sprite for 80 keys

The entire sound library lives in a single 1.9MB OGG sprite: ~80 key samples packed into one file, each mapped to a [startMs, durationMs] tuple for both the "down" and the "up" phase.

The fetch starts at module import time, before any component mounts (a <link rel="preload"> in the layout starts it even earlier), and the keyboard provider decodes the result into an AudioBuffer once. When you press a key, BufferSource.start(0, offset, duration) slices the right sample out of that buffer on demand, so the whole library costs one HTTP request and one decode.

Browser restrictions took the most debugging. Modern browsers suspend new AudioContexts until the user interacts with the page (thanks, autoplay policies). Keythm listens for the first keydown or pointerdown on the document and resumes the context immediately, so the audio pipeline is warm by the time you start typing.

The second surprise was macOS, which has a habit of swallowing keyup events after Cmd chords. A modifiersDownRef tracks which modifier keys are held so a missing keyup doesn't leave the keyboard state stuck.

Not everything goes through the sprite. "Faah mode" is an Easter egg that plays a dramatic sound effect on wrong keys, and it uses a plain HTML Audio element because it doesn't need the sprite's timing precision.

04

Catching bots that type at 120 WPM

The results screen shows six metrics: WPM, raw WPM, accuracy, consistency, elapsed time, and a character-level breakdown (correct, incorrect, extra, missed, corrected). Score 100+ WPM and confetti fires. A score only means something if a script can't post it, so Keythm runs 12 anti-cheat checks on every result.

The first layer is sanity checks (no keystrokes, non-finite numbers, a test shorter than 2 seconds) and hard thresholds. WPM above 300, raw WPM above 350, or more than 30 characters per second catches the obvious bots.

Those thresholds miss the more interesting cheat: a script that types at a plausible 120 WPM with inhuman steadiness. Catching that took statistical checks on the per-second WPM history instead of bounds on the final number:

  • Impossible bursts: any single second above 600 WPM.
  • AFK gaps: more than 3 consecutive seconds with no input in the middle of a test.
  • Flat history: every per-second WPM within 1 WPM of the others over 4+ seconds.
  • Perfect consistency: a consistency score of 99 or higher.

Calibrating these to avoid false positives on legitimate typists was the hardest part, so the flat-history and consistency checks only activate above 80 WPM. I didn't want slow but steady typists flagged for being steady.

05

Trade-offs

ChoseOverWhyCost
One OGG sprite with offset tuples80+ audio files, one per keyOne fetch, one decode, one lookup table. Separate files meant 80+ requests (or a bundling step to maintain), 80+ decode calls, and cache invalidation per file.Every offset was mapped by hand, a one-time cost paid during development.
localStorage plus Serwist precachingAccounts and server-side settingsNo auth, no round-trips, and the app works offline after the first visit.Personal bests live in one browser. Nothing syncs across devices.
Steadiness checks gated at 80 WPMRunning them on every resultConsistent typists below 80 WPM don't get flagged as bots.A steady script typing under 80 WPM only faces the threshold, burst and AFK checks.
Blocking inline script for the accent colorApplying the theme after React hydratesCorrect colors from the first frame, no flash.A script outside React that runs before the page can paint.
IntersectionObserver gating on the keyboardKeeping key listeners attached all the timeNo keyboard work while it's off-screen.The on-screen keyboard doesn't track keys while scrolled out of view.
Plain HTML Audio for faah modeAdding it to the spriteAn Easter egg doesn't need sprite precision.Two audio paths in the codebase instead of one.
06

Results

What shipped:

  • Per-key audio for ~80 keys from one 1.9MB file, one request and one decode per visit.
  • Four test modes, six result metrics and a WPM-over-time chart.
  • 12 anti-cheat checks: sanity checks, hard thresholds, and checks on the per-second WPM history.
  • 6 keyboard color schemes (Classic, Mint, Royal, Dolch, Sand, Scarlet) that also set the site accent, and 9 typing fonts.
  • An offline-first PWA: all state in localStorage, precached by Serwist, usable without a connection after the first visit.
07

What I'd change

The gap between "has sound" and "sounds right" was an order of magnitude more engineering than I expected: an eager fetch at import, a single decode, sprite slicing, autoplay workarounds, the macOS keyup fix. What I'd add is a measured keystroke-to-sound latency per browser. The target is under one frame, and I haven't measured it yet.

The anti-cheat thresholds also need data. Before trusting them on a public leaderboard, I want to check them against logged results: how often a real person trips a check, and how often a script slips under the 80 WPM gate.

A leaderboard is the next step. Drizzle and LibSQL already run the visit counter, so the database is in place. The rule stays the same: the server adds global stats, and the typing test keeps working without it.

08

FAQ

Q.01How does Keythm play a different sound for every key?

Keythm packs about 80 key samples into a single 1.9MB OGG sprite that is fetched once at module import and decoded once into an AudioBuffer. Each key maps to a [startMs, durationMs] offset for its down and up sound, and the Web Audio API slices the right sample out of that buffer on every keystroke instead of loading a file per key.

Q.02What typing test modes does Keythm have?

Keythm has four modes: timed (15 to 120 seconds), word count (10 to 100 words), curated quotes filtered by length, and zen, which runs until you press Shift+Enter. Every mode tracks live WPM, accuracy and consistency.

Q.03How does Keythm detect cheating in a typing test?

Keythm runs 12 anti-cheat checks on every result: sanity and hard-threshold checks like WPM above 300 or more than 30 characters per second, plus checks on the per-second WPM history for single-second bursts above 600 WPM, AFK gaps, a flat WPM line and near-perfect consistency. The flat-line and consistency checks only apply above 80 WPM so slow but steady typists don't get flagged.

Q.04How does Keythm calculate typing consistency?

Keythm snapshots WPM every second and scores consistency as 100 minus the coefficient of variation of those per-second WPMs. A high score means a steady rhythm and a low score means erratic bursts.

Q.05Does Keythm work offline?

Yes. Keythm is an offline-first PWA that keeps settings, personal bests and preferences in localStorage, and Serwist precaching lets it work without a connection after the first visit.

Q.06What stack is Keythm built with?

Keythm is built with Next.js, React, TypeScript and Tailwind CSS, using the Web Audio API for sound, Serwist for offline support, Recharts for the WPM-over-time chart and Motion for animation. Drizzle with LibSQL currently backs only a visit counter, so the typing test itself doesn't depend on a server.

Up Next

Nextdemy

A learning platform in a monorepo, with real payments, auth and content delivery

Web App