How Long Did You Actually Type? — Making Practice Time Deterministic, Tamper-Resistant, and Reproducible
A KeyDo postmortem: practice time was Date.now()'s end - start, so AFK, tab-switching, and wall-clock rollback all counted. Changed to accumulate 'accepted gaps between keystrokes,' dropping any gap over 60 seconds whole, layered with IME guards, a monotonic clock, and idempotent settlement so the number is both accurate and unfarmable.
KeyDo’s scores — WPM, accuracy, practice time — are all computed in the browser. I’ve written before about how the typing engine holds truth in a ref. This post covers a plainer, more easily overlooked number: how do you actually compute practice time accurately? It used to be Date.now()’s end - start, so a player going AFK, switching away, or even the system wall-clock rolled back by NTP all counted into “practice time.” This time it was rebuilt to be deterministic, reproducible, and unfarmable.
Active typing time: accumulate gaps, not wall-clock difference
The shift in the core idea: practice time is no longer “end time - start time,” but accumulating the accepted gap between each pair of valid keystrokes.
export function acceptActiveAction(state, now) {
let activeMs = current.activeMs
if (current.lastAcceptedAt !== null) {
const gapMs = now - current.lastAcceptedAt
if (gapMs >= 0 && gapMs <= MAX_ACCEPTED_GAP_MS) { // only accumulate reasonable gaps
activeMs += gapMs
}
// gapMs < 0 (wall-clock rollback) or > 60s (AFK) → don't accumulate the whole span; current key is new baseline
}
return activeTimeState(activeMs, now)
}
MAX_ACCEPTED_GAP_MS = 60_000: only an adjacent keystroke gap within [0, 60000]ms accumulates; a gap over 60 seconds is dropped whole, and this keystroke becomes the new baseline. That’s the deterministic implementation of “AFK doesn’t count” — type a while, go make coffee, come back and keep typing, and the multi-minute gap in between exceeds 60 seconds and doesn’t count. The boundary is precise to the millisecond: 59999ms and 60000ms count, 60001ms is ignored whole (closed interval).
There’s a companion suspend: on the page going to background (visibilitychange to hidden), blur, or pagehide, it clears “last keystroke time” so the first key after resume re-baselines. Note suspend is not “pause the timer” — the speed and challenge wall-clocks keep running in the background and the run still ends on time; it’s just that practice time doesn’t accumulate because the baseline was cleared. Two time semantics coexist: the countdown uses wall-clock, practice time uses accepted gaps.
The clock must be monotonic
That gapMs >= 0 guard above defends against the system wall-clock being rolled back (NTP correction, user changing the time) making the gap negative. But the more thorough approach is to change the clock source: timing and deadline judgment both use performance.now() (a monotonic clock, only increasing, unaffected by system time adjustment), and only truly-persisted timestamps use Date.now().
That way a rolled-back wall-clock can neither farm time (the gap doesn’t come out negative) nor end a run early (the deadline judgment uses the monotonic clock). Using a monotonic clock where a monotonic clock belongs is the bedrock of this kind of timing tamper-resistance.
IME: Chinese composition must not pollute the count
An easily-missed pit is the IME. During “composition,” Chinese and Japanese dispatch a batch of keydowns whose key === 'Process' or keyCode === 229, with isComposing = true. If unblocked, these get counted as ordinary characters, polluting keystroke and character counts and wrongly advancing the activeMs baseline.
The guard goes at the very front of every business keyboard handler:
export function shouldIgnoreBusinessKey(event, compositionActive) {
return Boolean(
compositionActive
|| event?.isComposing
|| event?.key === 'Process'
|| event?.keyCode === 229, // some browsers' composition first-key is neither isComposing nor Process
)
}
CompositionProvider maintains a shared flag via compositionstart / compositionend, with one crucial fallback: on window blur and document hidden, force-reset the flag — because switching away can lose the compositionend event, and without resetting, input gets permanently locked in the “composing” state and every subsequent key is ignored.
Settlement must be idempotent, no double-reporting
The root cause of duplicate score reporting is that the completion callback, pagehide, and unmount cleanup may fire several times in a row. The fix is a pure state of “a grow-only watermark + a one-time completion flag”:
export function claimPracticeDelta(state, activeMs) {
const persistedActiveMs = Math.max(current.persistedActiveMs, activeMs) // only bill the unbilled delta
return { state: ..., deltaMs: persistedActiveMs - current.persistedActiveMs }
}
export function claimCompletion(state) {
const accepted = !current.completionClaimed // completion accepted only once
return { state: settlementState(..., true), accepted }
}
pagehide, blur, Esc, tab-reopen, and unmount all go through the same settlement path, submitting only a deltaMs > 0 delta and writing a full score only when claimCompletion.accepted is true. Even if React StrictMode runs the callback twice, the watermark and completion flag block the duplicate.
Frame transitions must be atomic too
Games like word rain have another race: old code split “move the word, judge the landing, deduct a life, end, spawn a new word” across multiple setStates, and multiple triggers could double-deduct a life or double-submit. The fix compresses these into a single advanceRainFrame return, computing lives = Math.max(0, lives - missedCount) and endedNow = lives === 0 once per frame. The frame interval is also clamped to MAX_FRAME_MS = 100 — otherwise, tabbing back, a huge time gap would “teleport” words through the floor and lose all lives in an instant.
Aside: content determinism + per-identity isolation
Two additions. Quiz content used to use Math.random(), non-reproducible across devices and untestable; changed to a seeded RNG, with the seed from ${content}:${runId}:${blockIndex} — the same params yield the same text, reproducible and testable. And under anonymous multi-identity, practice data used to sit in global localStorage keys and bleed across identities; now each identity has its own namespace (keys prefixed keydo:user:${identity}:), with only device preferences global, plus an allowlist hard-blocking cross-privilege global writes.
Takeaways
- Time should “accumulate accepted gaps,” not be a “wall-clock difference.” end - start counts AFK and switching-away; accumulating adjacent keystroke gaps and dropping over-threshold spans whole is the “you really were typing” time. A boundary precise to the millisecond is what makes it testable;
- Use a monotonic clock where one belongs.
performance.now()is unaffected by system time adjustment; run timing and deadlines through it and a rolled-back wall-clock can neither farm time nor end a run early; - The IME guard goes at the very front, and must self-heal. Block composition’s
Process/229/isComposingat every handler’s entry; force-reset the flag on blur/hidden to prevent a lostcompositionendfrom permanently locking input; - Settlement is idempotent, so it survives repeated triggers. The completion callback + pagehide + unmount fire repeatedly; a “grow-only watermark + one-time completion flag” blocks duplicate reporting in pure state, with all exit paths sharing one settlement;
- Compress a frame’s several setStates into one atomic transition. The root cause of race-induced double-deductions and double-submits is often “one thing changed state several times” — fold it into a single reducer return, computed once per frame.
Comments