🍱 Lunchbox Hands

gamepad

Stick Drift, Dead Zones and Ghost Touches: Testing Input in a Browser

Why worn analog sticks drift, how radial dead zones hide it, what the Gamepad API reports, and how to catch touchscreen dead zones and ghost touches.

Your character creeps left while you’re making a sandwich. Your phone opens an app nobody tapped. Both feel like haunting, and both are the same kind of failure: a sensor reporting input that isn’t there, which a browser can measure in a couple of seconds if you know what the numbers mean.

What stick drift is, physically

Most analog sticks measure their position with two potentiometers, one per axis: a wiper contact sliding along a resistive track. The position is a voltage, and the voltage is only as good as the contact. GuliKit, which sells replacement sticks, puts the failure bluntly in its press release: the carbon film potentiometer “can be worn out and cause unknown liquids, dust, or residues inside which result in connection failure, performance degradation, and drifting.”

So drift is wear, not a software bug: the worn track no longer reads “centre” when the stick is centred, and the game faithfully walks your character that way.

The fix manufacturers now advertise is the Hall-effect stick. Instead of a sliding contact, a magnet moves with the stick and a Hall sensor reads the magnetic field. In the same GuliKit release, the sensor “is suspended and has no contact with the magnetic rotating shaft, which completely eliminates the problem of carbon film wearing.” No contact means no wear track. Springs, gimbals and calibration can still go wrong, so treat “no drift, ever” as marketing copy. What goes away is the mechanism behind most drift.

Nintendo’s EU/UK support page on Joy-Con drift states that Nintendo “will not charge you in the European Economic Area (EEA), UK and Switzerland for the repair of the responsiveness syndrome irrespective of whether this is caused by a defect or by wear and tear” (Nintendo UK). Its Joy-Con 2 page lists what to try first: check for remapped inputs, update firmware, remove skins, recalibrate (Nintendo support). Recalibration moves the software’s idea of “centre”; it can’t repair a worn track, but it’s free.

Dead zones, and why their shape matters

Games assume the stick won’t rest at exactly zero, so they ignore input below some small radius: the dead zone. Its shape matters. Game designer Josh Sutphin laid out the three common ones in Doing Thumbstick Dead Zones Right:

Dead zone typeHow it worksWhat it feels like
AxialZero each axis on its own if `x
RadialZero the whole vector if its length is under the thresholdNo snapping, but output jumps from 0 to ~threshold at the edge
Scaled radialRadial, then rescale the remaining travel back to 0..1Smooth from the edge of the dead zone to full tilt

Our gamepad tester uses the third kind. Here is the core of it from src/lib/gamepad.ts (lightly trimmed):

export function applyDeadZone(x: number, y: number, deadZone: number) {
  const dz = clamp(finite(deadZone), 0, 0.99)
  const fx = finite(x)
  const fy = finite(y)
  const mag = Math.hypot(fx, fy)
  if (mag <= dz || mag === 0) return { x: 0, y: 0 }
  const scaled = Math.min(1, (mag - dz) / (1 - dz))
  return { x: (fx / mag) * scaled, y: (fy / mag) * scaled }
}

Here are real outputs from running it (and its 1D sibling) with a 0.10 dead zone:

Raw input (x, y)Axial, per axisScaled radial
(0.07, 0.07)(0, 0)(0, 0), since magnitude 0.099 is inside the dead zone
(0.30, 0)(0.222, 0)(0.222, 0)
(1.00, 0)(1, 0)(1, 0), so full tilt still reaches 1
(0.09, 0.50)(0, 0.444)(0.080, 0.446)

The last row is the snapping problem in numbers. You’re pushing mostly up and a little right. The axial version throws the right-hand component away because 0.09 is under the threshold, so you move straight up. The radial version keeps the direction.

What the Gamepad API reports

The W3C Gamepad spec (Working Draft, 10 July 2025) is small. A page calls navigator.getGamepads() and gets back Gamepad objects with two arrays:

  • axes: “All axis values MUST be linearly normalized to the range [-1 .. 1].” For sticks, negative is left or up and positive is right or down.
  • buttons: each has a boolean pressed and a value that “MUST be linearly normalized to the range [0 .. 1]”. Face buttons jump between 0 and 1. Analog triggers sweep through the middle, which is how our tester shows L2/R2 as a bar.

When the browser recognises the device, it sets mapping to "standard" and reorders the inputs to the spec’s canonical layout. Buttons 0–3 are the bottom, right, left and top buttons of the right cluster. 4–7 are the front bumpers and triggers, 8 and 9 are the centre-left and centre-right buttons, 10 and 11 are the stick clicks, 12–15 are the D-pad, and 16 is the centre button. Axes 0/1 are the left stick and 2/3 the right. Anything the browser doesn’t recognise comes through with mapping: "" and a device-defined order.

That index-to-position mapping is why a tester can print the right labels. Here is how our tool labels the first ten standard buttons:

IndexXboxPlayStationSwitch Pro
0 (bottom)A✕B
1 (right)B○A
2 (left)X□Y
3 (top)Y△X
4 / 5LB / RBL1 / R1L / R
6 / 7LT / RTL2 / R2ZL / ZR
8 / 9View / MenuShare or Create / Options− / +

Nintendo swaps the letters relative to Xbox, so “bottom button” is B on a Switch pad and A on an Xbox pad. When mapping isn’t "standard", our tool deliberately falls back to “Button 0”, “Button 1” instead of guessing.

Why the page says “no controller detected”

This is intentional. The spec says: “To mitigate fingerprinting, getGamepads() returns an empty list before a gamepad user gesture has been seen.” A user gesture means pressing a button (or, for the gamepadconnected event, “when the user presses a button or moves an axis”). MDN’s guide adds the practical pattern: don’t hold on to the object from the event. Poll navigator.getGamepads() every animation frame. That’s what our tester does, and it stops polling while the tab is hidden.

The id string

Gamepad.id is free-form. MDN documents Firefox’s form as vendor id, product id and name separated by dashes; Chromium puts the ids in a parenthetical. Our parser on each style:

"DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)"
  → { name: "DualSense Wireless Controller", vendor: "054c", product: "0ce6" }
"54c-ce6-DualSense Wireless Controller"
  → { name: "DualSense Wireless Controller", vendor: "054c", product: "0ce6" }
"Xbox 360 Controller (XInput STANDARD GAMEPAD)"
  → { name: "Xbox 360 Controller" }   (no ids exposed; layout guessed from the name)

The vendor id picks the label set: 045e is Microsoft, 054c is Sony, 057e is Nintendo. Without one, we match words like “xbox” or “joy-con” in the name.

Rumble support is patchy

Vibration goes through gamepad.vibrationActuator.playEffect('dual-rumble', {...}), with duration, startDelay, and strongMagnitude/weakMagnitude from 0 to 1. The promise resolves to "complete" or "preempted" (MDN). Browser support, according to MDN’s browser-compat-data:

BrowserplayEffect
Chrome / Edge (Chromium)Yes, since Chrome 68
Safari (macOS)Yes, since 16.4
FirefoxNo
Safari on iOSNo

Our tester sends a half-second dual rumble (strong motor 1.0, weak 0.5) and tells you honestly when the browser exposes no actuator. Even in a supporting browser, some pads connected over Bluetooth or through third-party drivers may not report motors at all, so “not supported” doesn’t prove your motors are dead.

How our drift check works

The drift check in the gamepad tester is deliberately simple. Once you let go and press the button, it:

  1. Records every axis on every animation frame for 2 seconds (DRIFT_MS = 2000 in the component).
  2. For each axis, takes the largest absolute value seen. At rest, that’s the deviation from centre.
  3. Flags the axis as possible drift if that maximum exceeds 0.05 (DRIFT_THRESHOLD).
  4. Skips any axis whose every sample sits beyond ±0.9. That’s almost always a trigger reported as an axis, resting at −1, not a stick stuck at full tilt.

Here’s the output for a synthetic 120-frame run (about 2 seconds at 60 Hz). The left-stick Y axis sits around +0.06 and the other axes have small noise:

AxisMax deviationMeanVerdict
0 (left X)0.0080.000fine
1 (left Y)0.0700.060drift
2 (right X)0.012−0.012fine
3 (right Y)0.0040.004fine
4 (trigger)1.000−1.000pinned, ignored

The 0.05 line is our choice of cut-off, not a standard. Games differ, which is why the tester also has a dead-zone slider (0 to 0.5) that draws the ring around each stick. Drag it until the resting dot sits inside the ring, and you’ve found the smallest dead zone that would hide your drift. If that number is bigger than what your game allows, the stick needs a recalibration, a repair or a Hall-effect replacement.

One limit: two seconds at rest catches a stick that sits off-centre. It won’t catch a stick that only drifts after you flick it and it fails to spring back. For that, flick the stick, let go, and watch the live x/y readout for a few seconds.

The touchscreen version: dead zones and ghost touches

Touchscreens fail in two opposite ways: a dead zone ignores a real finger, and a ghost touch is the digitizer reporting a finger that isn’t there. Apple’s troubleshooting page for screens that are “too sensitive or respond intermittently to touch” lists the usual causes in its fixes: clean off “debris or water”, “disconnect any Lightning or USB-C accessories” and if that helps “try using a different outlet, cable, or charger”, and “remove any cases or screen protectors” (Apple Support).

The charger item sounds like folklore, but it isn’t. A capacitive panel detects a finger as a tiny change in charge, and a noisy charger injects its own. Silicon Labs’ Vadim Konradi describes charger interference that “may be confused with the measured charge movement due to a finger touch on the screen,” with the finger itself as the return path (Embedded).

How the browser sees touches

Our touchscreen test uses Pointer Events. They report mouse, pen and finger through one API, with pointerType set to "mouse", "pen" or "touch", and an empty string for devices the browser can’t identify. Two details matter for a test like this:

  • touch-action: none on the test surface. The spec says panning and zooming “cannot be suppressed by canceling a pointer event. Authors must instead use touch-action.” Without it, the browser takes over a swipe and the page gets a pointercancel instead of your stroke.
  • getCoalescedEvents(). Browsers may merge several pointer moves into one event. We read the merged samples too, so a fast swipe doesn’t skip cells and invent a dead zone.

navigator.maxTouchPoints claims to report how many simultaneous contacts the device supports. MDN notes phones typically say 5 and non-touch desktops say 0. Our tool doesn’t trust that number. Multi-touch mode counts the contacts that actually arrive and records the most it saw at once. (The OS can swallow some multi-finger gestures first, so a low count isn’t always the hardware.)

What each mode does

Paint splits the screen into a grid: about 80 CSS pixels per cell on coarse, 40 on fine. On a 390×844 phone viewport that works out to 5×11 cells coarse or 10×21 fine. Every cell your finger crosses turns green, and cells between two move events are filled by sampling the stroke at a quarter-cell step. When you exit, cells that never turned green are counted by screen region (top-left, centre, bottom-right and so on), so “14 unpainted cells, mostly bottom-right” points at a real patch. Edge cells are the exception: phones reserve edge swipes for system gestures, so start strokes inside the screen and drag outward.

Ghost touch watch is the hands-off test. It gives you a 1-second grace period to get your hands away, then logs every pointer event the screen reports for 10 seconds. A pointer-down always counts. A move counts only if something is in contact (buttons ≠ 0), so a resting mouse or a hovering pen isn’t a ghost. A synthetic run with one phantom tap and its follow-up move gives:

Detected 1 phantom contact (2 events) while nobody was touching the screen.
2.20s  down  touch#4  x=201  y=634
2.22s  move  touch#4  x=203  y=640

Ghosts that cluster in one spot point at damage or a lifted protector in that spot. Ghosts that stop when you unplug the charger and run the watch again point at the charger, which is exactly Apple’s advice. Both testers run entirely in your browser. No touch coordinates or controller data are sent anywhere.

A practical checklist

  • Controller not showing up? Press a button with the page focused. That’s the spec’s fingerprinting guard, not a fault.
  • Suspect drift? Run the drift check in the gamepad tester, then use the dead-zone slider to see how big a dead zone would hide it. Recalibrate first. On Joy-Cons in the EEA, UK or Switzerland, Nintendo’s free-repair policy covers wear.
  • Phone tapping by itself? Run the ghost watch in the touchscreen test twice, once on the charger and once off it, and remove the screen protector if the ghosts cluster.
  • Buying a used laptop or tablet? Add the screen test for dead pixels (see dead vs stuck pixels), the keyboard test for dead keys and rollover (background in keyboard ghosting and NKRO), and the mic test before the first call.
  • Mouse clicking twice? That’s the mouse version of phantom input. The mouse test flags chatter, and mouse double-clicking by itself? shows how to tell a worn switch from a settings problem.