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;
}
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
0and you'll ship a false "healthy" verdict. -
It's a polling API. There's no drift event; you call
getGamepads()every frame insiderequestAnimationFrameand 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),
};
}
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';
}
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)