Skip to content
All case studies

Case study

Measuring a human bit rate

A two-row keyboard, a sixty-second window, and a number reported with its confidence interval — because a measurement quoted without one is an anecdote.

5 min read

Client
Self-directed
Role
Design, engineering, and measurement
Stack
React 19, TypeScript, Vite, Vitest, Web Audio API
Achieved bit rate±0.52 at 95%, one 60-second scored run
7.66 bits/s
Accuracy102 correct, 3 incorrect — every miss an adjacent key
97.1%
Tests2,093 lines of test against 2,292 lines of source
165
Bundlebrotli, the whole application, two runtime dependencies
56.5 KB

Pursuit is an instrument. It answers one question — how many bits per second can a person emit through a keyboard — and it is built so that the answer can be checked rather than believed.

That constraint is the reason it exists in a portfolio whose other case study is about measuring itself. The interesting engineering here is not the game loop. It is everything done to stop the number being flattering.

Why bits, and not words per minute

Words per minute measures a specific task with a specific alphabet and a specific set of learned motor patterns. It cannot compare a keyboard against a speller, a switch scanner, or an implanted electrode array, because none of those emit words.

Information rate can. The measure used here is the achieved bit rate from Shenoy et al. (2021):

B = log₂(N − 1) · max(Sc − Si, 0) / t

N is the alphabet size, Sc and Si the correct and incorrect selections, and t the scoring window. Twenty-six letters, so each correct selection is worth log₂(25) — about 4.64 bits.

Note the N − 1. A real speller has to reserve at least one key for error correction, so the formula discounts one symbol. Pursuit reserves none, which means the discount is not merely correct here — it is conservative. The number this instrument reports is slightly lower than the task strictly earns, and that is the right direction for a figure you intend to publish.

The window is a parameter, and that is load-bearing

src/lib/pursuit/bitrate.ts · lines 21–42
/**
 * Achieved bit rate, Shenoy et al. (2021):
 *
 *     B = log2(N - 1) * max(Sc - Si, 0) / t
 *
 * `t` is an explicit parameter, never a baked-in 60. Three callers need
 * three different windows:
 *   - the final scored result passes exactly 60
 *   - the live in-run readout passes measured elapsed
 *   - familiarization passes its own practice window
 * Hard-coding 60 would make the coaching screen report a practice rate
 * three times too low, and the player would calibrate against it.
 *
 * Always returns a finite number. `Sc=0, Si=0, t=0` yields 0/0 = NaN if
 * computed naively, and that NaN propagates into monsterDistance and then
 * throws inside AudioParam.setTargetAtTime — killing the tick loop, and
 * with it the readout, the proximity strip, and possibly run termination.
 */
export function computeBps(sc: number, si: number, tSeconds: number): number {
  if (!(tSeconds > 0)) return 0; // also catches NaN
  return (LOG2_N_MINUS_1 * Math.max(sc - si, 0)) / tSeconds;
}

The scoring window is an argument rather than a constant, and the comment says why: three callers need three different windows. The scored run passes exactly 60. The live readout passes measured elapsed time. Familiarization passes its own practice window. Hard-code 60 and the coaching screen reports a practice rate three times too low — and the player calibrates against it, which is worse than showing nothing.

The guard on the first line matters more than it looks. Sc = 0, Si = 0, t = 0 computes 0/0, and that NaN propagated into the audio layer and threw inside AudioParam.setTargetAtTime, killing the tick loop — and with it the readout, the proximity strip, and run termination. Three characters of guard against a total failure that only appears when someone does nothing at all.

You can run it. Twenty seconds, scored by the function printed above — not a reimplementation of it, the same file this page just rendered.

The live demo needs a physical keyboard, so it is not offered here. The instrument measures how fast a person can press a specific key on sight; there is nothing meaningful to measure through a touchscreen, and a board of thirteen columns is nineteen pixels a key at this width.

Everything the demo would tell you is on this page either way — the formula above is the one that scored the published run.

Three bugs in fifty lines of key handling

Input is where a bit-rate instrument is most easily made to lie, because every one of these mistakes inflates the number.

src/lib/pursuit/keymap.ts · lines 21–71
/**
 * Resolve a keydown into the symbol it selects, or null if it selects none.
 *
 * Guard order matters and is not arbitrary — see the IME note below.
 */
export function resolveLetter(e: KeyboardEvent): Letter | null {
  // 1. Trust. Synthetic events are not selections. A dispatchEvent loop
  //    reading the live target out of the DOM would otherwise produce an
  //    unbounded bit rate. This is the single choke point for that.
  if (!e.isTrusted) return null;

  // 2. IME composition. MUST precede the event.code fallback in step 5.
  //    With a CJK IME active every keydown arrives as key:'Process'. That
  //    fails the length check in step 4, falls through to the code fallback,
  //    matches /^Key([A-Z])$/, and scores a press the IME actually consumed
  //    into a candidate window — while the letter never commits.
  //    keyCode 229 is the legacy sentinel for the same condition.
  //    `keyCode` is deprecated in general, but 229 remains the only reliable
  //    IME signal on some engines, so it is read deliberately here.
  const legacyKeyCode = (e as KeyboardEvent & { keyCode?: number }).keyCode;
  if (e.isComposing || legacyKeyCode === 229) return null;
  if (e.key === 'Process' || e.key === 'Dead' || e.key === 'Unidentified') return null;

  // 3. Modifiers and auto-repeat.
  //    Shift is deliberately NOT filtered: Shift+A and Caps Lock both
  //    produce key:'A' and both are legitimate ways to select A.
  //    e.repeat is filtered because a held key is not a series of decisions.
  if (e.ctrlKey || e.metaKey || e.altKey) return null;
  if (e.repeat) return null;

  // 4. Primary: event.key — the character the key PRODUCES.
  //    The task is symbolic: the player presses the key *labelled* with the
  //    letter they see. MDN recommends exactly this for determining which
  //    character a key event corresponds to.
  const k = e.key;
  if (k.length === 1) {
    const up = k.toUpperCase();
    if (up >= 'A' && up <= 'Z') return up as Letter;
  }

  // 5. Fallback: event.code, only for layouts where event.key is non-Latin
  //    (e.g. a Cyrillic layout, where the key labelled A produces 'ф').
  //
  //    Never the other way around: event.code reports PHYSICAL POSITION, so
  //    on AZERTY the key labelled Q reports KeyA. Using code as primary
  //    would score a correct press as an error.
  const m = /^Key([A-Z])$/.exec(e.code);
  if (m) return m[1] as Letter;

  return null;
}

isTrusted is the single choke point. Without it, a dispatchEvent loop that reads the live target out of the DOM produces an unbounded bit rate. It is one line, it is the first check, and it is the only thing standing between this instrument and a meaningless number.

IME composition is checked before the event.code fallback, and the order is not cosmetic. With a CJK input method active, every keydown arrives as key: 'Process'. That fails the single-character test, falls through to the code fallback, matches /^Key([A-Z])$/, and scores a press the IME actually consumed into a candidate window — while the letter never commits. Checked second, it would credit selections the player never made.

event.key is primary and event.code is only a fallback, because code reports physical position. On AZERTY the key labelled Q reports KeyA. Using code as the primary source would score a correct press as an error, on a whole class of keyboards, silently.

The number, and its uncertainty

A single scored run:

Correct selections102
Incorrect selections3
Windowexactly 60 s
Achieved bit rate7.66 bits/s
95% confidence half-width±0.52
Accuracy97.1%

The confidence interval is the part worth arguing for. Sc − Si over n trials at accuracy a has a standard deviation of 2·√(n·a·(1−a)) — each trial moves the net by +1 or −1, so its variance is four times a Bernoulli's. Propagated through the formula, a 60-second window resolves the rate to roughly ±0.5 bits per second.

Which means two results differing by less than about one bit per second are not meaningfully different. Without that stated, a run at 7.7 and a run at 7.3 look like a regression, and someone tunes something to chase noise. Quoting the interval is what turns the figure from a score into a measurement.

Standard rate, not practical rate

One qualification the headline needs, and it is not a small one.

7.66 bits/s is the standard achieved bit rate. The formula credits log₂(N − 1) per net-correct selection and treats every error as equivalent — it has no notion of which wrong key was pressed.

The telemetry says the errors were not equivalent. All three misses in that run were spatial neighbours: I→U, G→F, F→E. They are near-misses in space, not confusions of symbol.

That cuts both ways for a real system. A near-miss is cheaper to correct than a random error, because the intended target is one of a small set of adjacent keys — so a practical rate might be higher. But the standard formula also gives no credit for that structure, so it is not simply an underestimate either.

No practical rate was computed here, and the page will not imply one. Turning an error-position distribution into a corrected throughput needs an error-correction model that the task does not specify. What can be said is narrower and true: the instrument measures the standard quantity, which is the one comparable across studies, and the error structure is visible in the raw telemetry for anyone who wants to model it.

That distinction only exists because the export logs every keystroke — target, pressed, raw key, raw code, timestamp — rather than a running total. A summary figure would have destroyed the finding before anyone could notice it.