DEV Community

Zihang Dong 董子航
Zihang Dong 董子航

Posted on Fully Autonomous

Frame Rate Is Not a Stopwatch: Designing Honest Browser Aim Metrics

A browser aim trainer is a useful small systems problem: the interface looks like a game, but its output looks like a measurement. That mismatch is where bugs become misleading claims.

Rendering a target is straightforward. Deciding what counts as a shot, how much time a hidden tab earns, and whether two rounds are comparable requires a more explicit contract.

This tutorial develops that contract with small, executable JavaScript examples. The implementation context is the browser aim trainer I maintain at ToolKnit, which has separate click, body-hit, headshot, and head-tracking tasks. That is a case reference, not evidence of improved game performance. This article was prepared with AI assistance and checked against the implementation; examples below are simplified teaching code rather than a complete copy of the application.

Start with three different clocks

An interactive task usually has at least three notions of time:

  1. Wall time: how long the person has had the page open.
  2. Active task time: how long a valid round has actually been running.
  3. Observed tracking time: the active intervals for which the application has observations supporting an on-target classification.

They answer different questions. A countdown belongs to the interface, not necessarily the scored task. A pause belongs in the history of a round, but should not silently earn tracking credit. An unknown interval should not become a positive observation merely because the next frame looks good.

Writing elapsed += 1000 / 60 in every animation callback confuses frame count with time. Switching to timestamps fixes that particular bug, but does not answer the semantic questions.

MDN documents that animation callbacks generally follow display refresh and are paused in most browsers in background tabs. Frame-driven measurements must account for both behaviors.

Define a clock that can reject an interval

Here is a minimal active clock. The caller provides timestamps, making the arithmetic testable without a browser:

class ActiveClock {
  constructor(maxGapMs = 250) {
    this.maxGapMs = maxGapMs;
    this.elapsed = 0;
    this.last = null;
  }
  resume(now) { this.last = now; }
  pause() { this.last = null; }
  advance(now) {
    if (this.last === null) return { dt: 0, gap: false };
    const dt = Math.max(0, now - this.last);
    this.last = now;
    if (dt > this.maxGapMs) {
      this.last = null;
      return { dt: 0, gap: true };
    }
    this.elapsed += dt;
    return { dt, gap: false };
  }
}

const clock = new ActiveClock();
clock.resume(1000);
console.assert(clock.advance(1016).dt === 16);
console.assert(clock.advance(5000).gap === true);
console.assert(clock.elapsed === 16);
console.assert(clock.advance(5016).dt === 0);
Enter fullscreen mode Exit fullscreen mode

The last assertion matters. Rejecting a long interval also disarms the clock. The next animation callback cannot resume it accidentally; a deliberate transition must call resume() again.

The 250 ms cutoff is an application policy used in this example, not a web standard, a measured latency guarantee, or a scientific boundary. A severe performance stall can trigger it even when the player never left the page. That is a tradeoff: prefer an interrupted round over pretending an unobserved interval was valid practice.

Use timestamps from a consistent monotonic time basis. performance.now() is appropriate for elapsed durations in a page; Date.now() is useful for a human-readable record date. A more precise timestamp does not reveal the physical moment a pixel appeared on a display, nor remove input-device and operating-system latency.

A successful collision is not always a completed target

Consider a headshot task. A shot that hits the torso is a valid shot and a collision, but it should not complete the target.

Separate those facts before designing the result screen:

function newStats() {
  return { shots: 0, head: 0, body: 0, air: 0, completed: 0 };
}

function recordShot(s, part, mode) {
  s.shots += 1;
  if (part === 'head') s.head += 1;
  else if (part) s.body += 1;
  else s.air += 1;

  const completes = mode === 'head'
    ? part === 'head'
    : Boolean(part);
  if (completes) s.completed += 1;
  return completes;
}

const s = newStats();
console.assert(recordShot(s, 'body', 'head') === false);
console.assert(recordShot(s, 'head', 'head') === true);
console.assert(recordShot(s, null, 'head') === false);
console.assert(s.shots === s.head + s.body + s.air);
console.assert(s.completed === 1);
Enter fullscreen mode Exit fullscreen mode

Geometry supplies part; it does not decide whether the round was running or whether the input was accepted. The event layer checks those conditions first. Otherwise a click on the start overlay, a repeated key event, or a shot received after completion may mutate the score.

For a 3D scene, classify the nearest relevant intersection before applying scoring rules. Do not award a headshot because the ray eventually intersects a head behind an occluding surface.

Name the denominator on the screen

Suppose a constructed round contains ten shots: two head hits, six other hits, and two misses.

Metric Formula Result
Hit accuracy All mannequin hits / accepted shots 80%
Head-hit rate Head hits / accepted shots 20%
Head share of hits Head hits / all mannequin hits 25%

Those percentages describe the same inputs. None is interchangeable with the others. A label such as “headshot rate” without a definition hides an important product decision.

Keep missing observations distinct from zero:

const ratio = (numerator, denominator) =>
  denominator > 0 ? numerator / denominator : null;

console.assert(ratio(0, 0) === null);
console.assert(ratio(0, 10) === 0);
Enter fullscreen mode Exit fullscreen mode

Render the first case as “—” or “No shots,” not as an apparently measured 0%. Preserve the raw counts so the user can interpret a percentage from a small sample.

Also avoid calling target acquisition “pure reaction time.” Finding a target, moving a pointer or camera, and clicking are part of that interval. Different target sizes and distances affect it. It is a task duration, not a clinical measurement of a person.

Tracking needs time weighting and an uncertainty policy

Counting on-target frames rewards a different unit on a 60 Hz display than on a 144 Hz display. Instead, accumulate observed time and divide by active time.

One conservative sampling rule is to credit an interval only when both endpoints were on target:

function newTracking() {
  return { previousOn: false, credited: 0, streak: 0, longest: 0 };
}

function observe(t, onTarget, dt) {
  if (onTarget && t.previousOn) {
    t.credited += dt;
    t.streak += dt;
    t.longest = Math.max(t.longest, t.streak);
  } else {
    t.streak = 0;
  }
  t.previousOn = onTarget;
}

const tracking = newTracking();
observe(tracking, true, 16);  // establish the first observation
observe(tracking, true, 16);  // credit one supported interval
observe(tracking, false, 16);
console.assert(tracking.credited === 16);
console.assert(tracking.longest === 16);
Enter fullscreen mode Exit fullscreen mode

This is still an approximation. Two on-target endpoints do not prove continuous on-target movement between them. The policy avoids assigning a whole unknown gap to a favorable final observation; it does not reconstruct a missing trajectory.

When the active clock rejects a gap, do not call observe() for that interval. On pause, clear the previous observation and current streak. Keep accumulated historical credit, but do not join the two sides of a pause into one continuous streak.

Treat interruptions as data, not embarrassment

A compact lifecycle can look like this:

idle -> loading -> ready -> countdown -> running -> completed
                                running -> paused -> countdown
                                paused  -> completed (aborted)
Enter fullscreen mode Exit fullscreen mode

Each transition owns cleanup. Leaving running stops scheduled work and freezes the task clock. Completion freezes the result before any result animation. A visibility change, window blur, viewport change, pointer-lock loss, or rejected timing gap routes through the same pause operation.

An interrupted round can remain useful history. It simply should not replace an uninterrupted personal best. “Save the record” and “eligible for best” are separate decisions:

const eligibleForBest = r =>
  r.completed === true &&
  r.interrupted === false &&
  r.elapsed > 0;
Enter fullscreen mode Exit fullscreen mode

Pointer Lock adds an asynchronous boundary. Requesting it does not mean it succeeded. Handle success, rejection, and a request that never reaches the expected locked state. A stale loading or lock result must not revive a scene the user has already left.

Full-screen and raw mouse input are separate capabilities. A fallback can keep a task usable, but record the actual input path rather than pretending mouse, raw mouse, touch, and drag fallback are identical.

A personal best belongs to an experiment configuration

Sorting every round by duration produces a misleading leaderboard. Ten large static targets are not fifty small moving targets.

A comparison key should encode the task version, relevant settings, input category, and training-area dimensions. Decide explicitly whether cosmetic settings are relevant. Store an immutable round configuration rather than a reference to controls that can change later.

This only controls variables the application knows. It cannot automatically identify physical mouse DPI, display latency, posture, or every browser setting. Random target layouts add variation even within a matching configuration. Matching profiles make comparisons more meaningful; they do not turn a browser game into a controlled laboratory experiment.

Local history also needs ordinary defensive engineering: versioned records, bounded retention, finite numeric fields, count invariants, and visible storage-failure feedback. Do not let a malformed local record break the entire UI. Do not call browser-local persistence a backup or anti-cheat system.

Test the contract before polishing the score animation

The examples above run with plain JavaScript. A useful test matrix adds these integration cases:

  • A torso hit in headshot mode increments the shot count without replacing the target.
  • A hidden tab resumes as interrupted, without receiving unknown tracking credit.
  • The final accepted hit freezes timing immediately.
  • A failed lock request leaves the timer stopped.
  • Leaving during asynchronous renderer loading prevents a late scene from mounting.
  • Storage failure does not prevent practice, but the UI stops promising durable history.

Synthetic clicks validate the application contract; their completion times are not human performance results. A unit test verifies arithmetic, not monitor latency or successful transfer to a specific FPS game.

The reusable lesson is simple: a convincing number needs a definition, an observation boundary, and a comparison rule. That applies to focus timers, typing tests, interaction benchmarks, and training tools just as much as it applies to aim practice.

References

No game-performance, medical, or general-attention improvement is established by the examples in this article.

Top comments (0)