<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Taimoor Bamazai]]></title><description><![CDATA[Taimoor Bamazai]]></description><link>https://taimoorbamazai.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Taimoor Bamazai</title><link>https://taimoorbamazai.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 05 Sep 2026 13:28:45 GMT</lastBuildDate><atom:link href="https://taimoorbamazai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why navigator.getGamepads() Returns 0.0 for a Drifting Analog Stick]]></title><description><![CDATA[Chromium sanitizes gamepad input before your JavaScript ever sees it. The rule is small, deliberate, and documented only in the source. It also means that if you write a stick drift detector the obvio]]></description><link>https://taimoorbamazai.hashnode.dev/navigator-getgamepads-returns-zero-stick-drift</link><guid isPermaLink="true">https://taimoorbamazai.hashnode.dev/navigator-getgamepads-returns-zero-stick-drift</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Browsers]]></category><category><![CDATA[debugging]]></category><category><![CDATA[chromium]]></category><dc:creator><![CDATA[taimoor bamazai]]></dc:creator><pubDate>Thu, 06 Aug 2026 18:24:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a74c74890b23419a0a528e6/3e7c96fd-268e-4cf2-b756-7edacdc787a0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Chromium sanitizes gamepad input before your JavaScript ever sees it. The rule is small, deliberate, and documented only in the source. It also means that if you write a stick drift detector the obvious way, it will confidently pass the most badly worn controllers you test it on.</p>
<p>I found this the expensive way: by shipping <a href="https://joycheck.io/stick-drift-test/">a browser-based drift detector</a> that had the failure mode backwards.</p>
<h2>The symptom</h2>
<p>The tool measured resting position. Plug in a controller, leave the sticks alone, sample <code>axes</code> over a rolling window, and report the offset from centre. Mild wear showed up exactly as expected, around 0.05 to 0.09. Badly worn controllers, the ones a user is actually worried about, came back as a clean <code>0.0</code> on every axis. Perfect health.</p>
<p>Not noise. Not a rounding artifact. A hard zero, every frame, for a stick you could watch drifting on screen in any native tool.</p>
<h2>The mechanism</h2>
<p>It is in <code>device/gamepad/gamepad_pad_state_provider.cc</code>. Line 23:</p>
<pre><code class="language-cpp">const float kMinAxisResetValue = 0.1f;
</code></pre>
<p>And the sanitization pass, roughly 130 lines down:</p>
<pre><code class="language-cpp">uint32_t full_axis_mask = (1 &lt;&lt; pad-&gt;axes_length) - 1;
if (pad_state-&gt;axis_mask != full_axis_mask) {
  for (size_t axis = 0; axis &lt; pad-&gt;axes_length; ++axis) {
    if (!(pad_state-&gt;axis_mask &amp; 1 &lt;&lt; axis)) {
      if (fabs(pad-&gt;axes[axis]) &lt; kMinAxisResetValue) {
        pad_state-&gt;axis_mask |= 1 &lt;&lt; axis;
      } else {
        pad-&gt;axes[axis] = 0.0f;
      }
    }
  }
}
</code></pre>
<p>Read the else branch carefully. Every axis starts life masked. Chromium will not expose its real value until it has personally observed that axis sitting below 0.1 at least once. Until then the value is not clamped, not smoothed, and not flagged. It is replaced with <code>0.0f</code>.</p>
<p>The rationale in the source is a privacy one, and it is a good rationale. A controller with a worn potentiometer, or one with something leaning on a stick, streams input the user never produced. Browsers treat gamepad activity as a user gesture, so that phantom input could expose gamepad information to a page the user never interacted with. Requiring an at-rest reading before trusting an axis closes that hole.</p>
<p>So this is not a bug. It is anti-fingerprinting sanitization working exactly as designed. It just happens to be catastrophic for the one use case that cares about resting position.</p>
<h2>The inverted failure mode</h2>
<p>Line the numbers up and the shape of the problem becomes clear. Take a detector with a warning threshold at 0.05 and a failure threshold at 0.15, against Chromium's 0.1 mask:</p>
<table>
<thead>
<tr>
<th>Actual drift</th>
<th>Reads below 0.1 at rest?</th>
<th>Mask clears?</th>
<th>Reported</th>
</tr>
</thead>
<tbody><tr>
<td>0.05 to 0.09 (mild)</td>
<td>yes</td>
<td>immediately</td>
<td>correct value</td>
</tr>
<tr>
<td>0.10 and above (severe)</td>
<td>no</td>
<td>never</td>
<td><strong>0.0, healthy</strong></td>
</tr>
</tbody></table>
<p>A diagnostic built this way detects exactly the problems that do not matter and goes blind precisely as the problem becomes real. It does not degrade at the margin. It inverts.</p>
<p>Worse, it inverts silently. There is no error, no <code>NaN</code>, no console warning. You get a well-formed float that says the hardware is fine.</p>
<h2>Why you cannot simply check for zero</h2>
<p>The obvious patch is to treat <code>0.0</code> as suspicious. That does not work, because a genuinely centred healthy stick also reports <code>0.0</code>. Masked and healthy are indistinguishable from a single sample. The API exposes no mask state.</p>
<p>The second instinct is to wait until the axis reads near centre, then start trusting it:</p>
<pre><code class="language-javascript">// WRONG
if (Math.abs(pad.axes[i]) &lt; 0.08) trusted[i] = true;
</code></pre>
<p>This is worse than useless. A masked axis reports <code>0.0</code>, and <code>0.0</code> passes that test on the very first frame. You have written a check that is satisfied by the exact condition it was supposed to detect.</p>
<h2>The one signal that does prove the mask is gone</h2>
<p>Go back to the else branch. While an axis is masked, any reading at or above 0.1 gets overwritten with zero. So a masked axis is <strong>incapable</strong> of reporting a large value.</p>
<p>Which gives you the inference:</p>
<blockquote>
<p>If you have observed <code>|axes[i]| &gt; 0.6</code>, that value survived sanitization. Therefore the mask for axis <code>i</code> is cleared, and everything it reports from now on is real.</p>
</blockquote>
<p>A large reading is the proof. Not a small one. This is the part that is easy to get backwards, and getting it backwards is what produces a detector that looks rigorous and measures nothing.</p>
<p>Physically this is also why a full stick rotation fixes the problem. Rotating the stick through its full range takes each axis from one extreme to the other, and on the way it crosses through the centre, dipping under 0.1 even when the stick's <em>resting</em> position is offset by 0.3. The mask clears mid-rotation. This is why a controller that has been used for thirty seconds reports drift correctly, while one that was plugged in and immediately tested does not.</p>
<p>That last detail matters more than it sounds. The mask is sticky for the device's connection lifetime in the browser process, and it is shared across tabs and reloads. So it bites exactly one population: the user who plugs in a controller, goes straight to a testing page, and carefully leaves the sticks alone because that is what testing resting position sounds like it should involve. That is the entire audience for a drift test.</p>
<p>If your UI says "keep both sticks centred", you have written an instruction that guarantees the bug.</p>
<h2>Detecting drift anyway</h2>
<p>Track two facts per axis, and gate the verdict on them:</p>
<pre><code class="language-javascript">const SWEEP_HIGH = 0.6;  // proves the mask is cleared
const SWEEP_LOW  = 0.08; // proves we have seen a real release

const sweep = new Map();

function trackSweep(pad) {
  let axes = sweep.get(pad.index);
  if (!axes || axes.length !== pad.axes.length) {
    axes = Array.from(pad.axes, () =&gt; ({ high: false, low: false }));
    sweep.set(pad.index, axes);
  }
  for (let i = 0; i &lt; pad.axes.length; i++) {
    const magnitude = Math.abs(pad.axes[i]);
    // Order matters: only count a low reading once we know values are real.
    if (magnitude &gt; SWEEP_HIGH) axes[i].high = true;
    if (axes[i].high &amp;&amp; magnitude &lt; SWEEP_LOW) axes[i].low = true;
  }
  return axes;
}

function axisIsTrustworthy(axes, i) {
  return axes[i].high &amp;&amp; axes[i].low;
}
</code></pre>
<p>The ordering in that loop is the whole fix. <code>low</code> is only allowed to latch after <code>high</code> has, because before <code>high</code> a low reading is worthless: it is probably the mask.</p>
<p>Then let the verdict be three-valued rather than two-valued:</p>
<pre><code class="language-javascript">function driftVerdict(pad, axes, i) {
  if (!axisIsTrustworthy(axes, i)) return 'unverified';
  const resting = Math.abs(pad.axes[i]);
  if (resting &gt;= 0.15) return 'drift';
  if (resting &gt;= 0.05) return 'mild';
  return 'ok';
}
</code></pre>
<p><code>unverified</code> is not a cosmetic addition. It is the honest state, and without it the tool is forced to render a masked axis as healthy. Ask the user to rotate the stick fully and release it, and only then report.</p>
<p>One asymmetry worth building on deliberately: masking can only ever hide drift, never invent it. A non-zero reading has already survived sanitization. So a <em>positive</em> drift result is trustworthy immediately and should never be gated behind the sweep. Gate the all-clear, not the alarm.</p>
<h2>Buttons have the same problem, and it is nastier</h2>
<p>The button branch, immediately below the axis one, applies the same idea:</p>
<pre><code class="language-cpp">if (!pad-&gt;buttons[button].pressed) {
  pad_state-&gt;button_mask |= 1 &lt;&lt; button;
} else {
  pad-&gt;buttons[button].pressed = false;
  pad-&gt;buttons[button].value = 0.0f;
}
</code></pre>
<p>A button must be seen released before it is trusted. So a button that is physically stuck down, one of the most common controller faults there is, is reported as never pressed, with an analog <code>value</code> of <code>0.0</code> to match. It does not appear as stuck. It appears as absent.</p>
<p>For a hardware test that is the worst possible rendering, because "this button never fires" and "this button is jammed on" call for completely different repairs, and the API collapses them into the same output. Track a released-observed bitmask per button and render anything unconfirmed as unverified rather than OK.</p>
<h2>Testing this without a broken controller</h2>
<p>You do not need worn hardware. You need a fake one, plus a simulation of the mask.</p>
<p>Replace <code>navigator.getGamepads</code> with a stub that returns a synthetic pad, then apply Chromium's own sanitization rules to your synthetic values before handing them over. Roughly forty lines reproduces the behaviour:</p>
<pre><code class="language-javascript">function maskedPad(rawAxes, state) {
  return rawAxes.map((v, i) =&gt; {
    if (state.cleared[i]) return v;
    if (Math.abs(v) &lt; 0.1) { state.cleared[i] = true; return v; }
    return 0.0;
  });
}
</code></pre>
<p>Drive that with a scripted sequence: a pad whose true resting offset is 0.30, held still for two seconds, then rotated, then released. A correct detector reports <code>unverified</code>, then <code>drift</code>. An incorrect one reports <code>ok</code> for the entire run and never changes its mind.</p>
<p>Running our old implementation through that harness reproduced the failure on both attempts. The replacement passed all sixteen scripted cases. That gap, two versus sixteen, existed before either version touched real hardware, which is the argument for building the harness at all.</p>
<h2>Two related limits, since they surface in the same place</h2>
<p><strong>Update rate is capped.</strong> <code>device/gamepad/gamepad_provider.cc</code> line 78 sets <code>kPollingIntervalMilliseconds = 4</code>, annotated in the source as roughly 250 Hz. A 1000 Hz controller and a 250 Hz controller are indistinguishable from JavaScript. Anything you measure in a browser is <code>min(controller rate, browser delivery rate, your sampling rate)</code>, so calling the result a controller's polling rate is not accurate. Observed update rate, browser limited, is the honest label.</p>
<p><code>Gamepad.id</code> <strong>has no standard format.</strong> On Chrome and Edge on Windows, every XInput device identifies as <code>Xbox 360 Controller (XInput STANDARD GAMEPAD)</code>, with no vendor or product ID, which makes a Series X pad indistinguishable from a decade-old 360 pad. Firefox on Windows reports the string <code>xinput</code> and nothing else. Other platform and browser combinations do expose VID and PID, in at least three mutually incompatible formats, one of which uses unpadded hex. If you are doing per-model analysis, measure how often you can identify the model at all before trusting any of it.</p>
<h2>The general lesson</h2>
<p>The Gamepad API is not a sensor feed. It is a sanitized, privacy-filtered view of one, and the filter is tuned for games, where a stick that has never been touched genuinely should read zero. Diagnostics inherit assumptions that were never written for them.</p>
<p>Before you trust any browser input value, find out what the browser does to it first. In this case that is about twenty lines of C++, it is public, and reading it would have saved me a release.</p>
<hr />
<p><em>Written by Taimoor Bamazai, founder of Elites Algorithm (Dublin, Ireland and Pakistan). Builder of JoyCheck and its privacy-first browser controller diagnostics.</em></p>
]]></content:encoded></item></channel></rss>