DEV Community

cw chen
cw chen

Posted on

What the Gamepad API actually tells you about stick drift (with a working verdict

What the Gamepad API actually tells you about stick drift (with a working verdict algorithm)

A player files a bug: "my controller drifts." You open the browser, call navigator.getGamepads(), and see a resting value of 0.03. Is that a defective stick — or electrical noise?

A single reading can't answer that. Here's how to build a verdict that can, and the traps the Gamepad API sets along the way.

1. The API gives you samples, not facts

function poll(): Gamepad | null {
  const pads = navigator.getGamepads?.() ?? [];
  for (const p of pads) if (p) return p;
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Three things bite everyone eventually:

  • No data before user interaction. Browsers only populate gamepad state after a button press or stick move (by design — same policy as audio). Your tester must prompt "press a button first" or every axis reads 0 and you'll ship a false "healthy" verdict.
  • It's a polling API. There's no drift event; you call getGamepads() every frame inside requestAnimationFrame and diff the values yourself.
  • You read what the OS reports, not the potentiometer. Deadzone compensation may already be applied somewhere in the driver stack. That's a behavioral measurement, not factory calibration — fine for triage, wrong for QA-grade claims.

2. One sample lies; a window doesn't

At rest, a healthy stick wiggles around zero — typically 0.02–0.04 of electrical noise that averages out. A drifting stick stays offset and creeps upward. The distinction isn't the magnitude of one reading, it's the shape of the distribution over a window.

So accumulate instead of judging point-in-time:

interface StickStats {
  n: number;
  maxAbsX: number;
  maxAbsY: number;
  sumAbsX: number;
  sumAbsY: number;
}

function accumulate(stats: StickStats, x: number, y: number): StickStats {
  return {
    n: stats.n + 1,
    maxAbsX: Math.max(stats.maxAbsX, Math.abs(x)),
    maxAbsY: Math.max(stats.maxAbsY, Math.abs(y)),
    sumAbsX: stats.sumAbsX + Math.abs(x),
    sumAbsY: stats.sumAbsY + Math.abs(y),
  };
}
Enter fullscreen mode Exit fullscreen mode

Then classify the window:

type Verdict = 'insufficient' | 'idle' | 'ok' | 'warn' | 'bad';

function judgeDrift(s: StickStats, threshold: number): Verdict {
  if (s.n < MIN_SAMPLES) return 'insufficient'; // don't guess with 5 samples
  const meanAbs = (s.sumAbsX + s.sumAbsY) / (2 * s.n);
  if (meanAbs < 0.005 && s.maxAbsX < 0.01) return 'idle'; // stick never touched?
  if (meanAbs >= threshold * 1.5) return 'bad';
  if (meanAbs >= threshold) return 'warn';
  return 'ok';
}
Enter fullscreen mode Exit fullscreen mode

This is (a simplified version of) the actual engine we ship — judgeDrift deliberately returns 'insufficient' and 'idle' as first-class verdicts. Those two strings are what separate an honest tester from a random-number generator:

  • 'insufficient': not enough samples yet. Showing a verdict at 5 samples is how testers "detect" drift that isn't there.
  • 'idle': the stats suggest the stick was never moved during calibration, so the baseline is meaningless.

3. Pick the threshold from user experience, not from the datasheet

Our defaults: ~0.05 sustained offset starts to be perceptible in-game; 0.08+ is noticeable in most titles; 0.15+ is severe. These are behavioral thresholds — derived from what players report feeling, not from potentiometer specs. Calibrate your own against real reports; the number that matters is "resting offset at which players complain," and that's measurable from support tickets.

4. The check resting drift can't do: roundness

Some faults — travel loss, center jumps — never show at rest. Have the user trace two slow full circles and fit the samples:

  • 1.00 = perfect circle
  • > 1.3 = travel loss or a center jump somewhere in the lap

That's the number that catches the controller which "feels fine at rest but won't full-sprint in game."

5. Ship the honesty

Everything above runs locally, nothing uploads, and the full verdict logic (drift, key chatter, double-click faults, dead zones) is open source, MIT:

github.com/chencw6578-commits/gearlint-verdicts​ — npm install gearlint-verdicts if you want the un-simplified engine with all 58 tests.

If you'd rather see it running than read code: GearLint's controller test shows the raw numbers next to the verdict, including the roundness check.

The principle we build the whole site on: show the raw number, say what the method can't measure, never present a guess as a measurement.


If you've built input handling on top of the Gamepad API, I'd genuinely like to hear how you handle the noise-vs-drift distinction — thresholds are a judgment call and mine aren't gospel.

Top comments (0)