When you plug in a controller, you mash buttons, move the sticks, pull the triggers… and as a developer, you see none of it. The browser’s picking it up, sure, but unless you’re logging numbers in the console, it’s invisible. That’s the headache with the Gamepad API.

It’s been around for years, and it’s actually pretty powerful. You can read buttons, sticks, triggers, the works. But most people don’t touch it. Why? Because there’s no feedback. No panel in developer tools. No clear way to know if the controller’s even doing what you think. It feels like flying blind.

That bugged me enough to build a little tool: Gamepad Cascade Debugger. Instead of staring at console output, you get a live, interactive view of the controller. Press something and it reacts on the screen. And with CSS Cascade Layers, the styles stay organized, so it’s cleaner to debug.

In this post, I’ll show you why debugging controllers is such a pain, how CSS helps clean it up, and how you can build a reusable visual debugger for your own projects.

Live Demo of the Gamepad Debugger showing recording, exporting, and ghost replay in action.

By the end, you’ll know how to:

  • Spot the tricky parts of debugging controller input.
  • Use Cascade Layers to tame messy CSS.
  • Build a live Gamepad debugger.
  • Add extra functionalities like recording, replaying, and taking snapshots.

Alright, let’s dive in.

Why Debugging Gamepad Input Is Hard

Just the thought of building a game or web app where a player uses a controller instead of a mouse could make you nervous. You need to be able to respond to actions like:

  • Did they press A or B?
  • Is the joystick tilted halfway or fully?
  • How hard is the trigger pulled?

The Gamepad API exposes and displays all of the information you need, but only as arrays of numbers. Each button has a value (e.g., 0 for not pressed, 1 for fully pressed, and decimals for pressure-sensitive triggers), and each joystick reports its position on the X and Y axes.

Here’s what it looks like in raw form:

// Example: Reading the first connected gamepad
const gamepad = navigator.getGamepads()[0];
 
console.log(gamepad.buttons.map(b => b.value));
// [0, 0, 1, 0, 0, 0.5, 0, ...]
 
console.log(gamepad.axes);
// [-0.24, 0.98, -0.02, 0.00]
 

Is it useful? Technically, yes. Easy to debug? Not at all.

Problem 1: Invisible State

When you press a physical button, you feel the click, right? But in your code, nothing moves on screen unless you manually wire up a display. Unlike keyboard events (which show in browser dev tools) or mouse clicks (which fire visible events), gamepad input has no built-in visual feedback.

To illustrate the difference, here’s how other input methods give you immediate feedback:

// Keyboard events are visible and easy to track
document.addEventListener('keydown', (e) => {
  console.log('Key pressed:', e.key);
  // Outputs: "Key pressed: a"
  // You can see this in DevTools, and many tools show keyboard input
});

// Mouse clicks provide clear event data
document.addEventListener('click', (e) => {
  console.log('Clicked at:', e.clientX, e.clientY);
  // Outputs: "Clicked at: 245, 389"
  // Visual feedback is immediate
});

// But gamepad input? Silent and invisible.
const gamepad = navigator.getGamepads()[0];
if (gamepad) {
  console.log(gamepad.buttons[0]); 
  // Outputs: GamepadButton {pressed: false, touched: false, value: 0}
  // No events, no DevTools panel, just polling
}

The gamepad doesn’t fire events when buttons are pressed. You have to constantly poll it using requestAnimationFrame, checking values manually. There’s no built-in visualization, no dev tools integration, nothing.

This forces you to keep going back and forth between your console and your controller just to keep logging values, interpreting numbers, and mentally mapping them back to physical actions.

Problem 2: Too Many Inputs

A modern controller can have up to 15+ buttons and 4+ axes. That’s over a dozen values updating at once.

Xbox vs. PlayStation
Both Xbox and PlayStation controllers pack 15+ buttons each, and they’re laid out differently. Debugging across platforms means handling all that variety. (Large preview)

Even if you are able to log them all, you’ll quickly end up with unreadable console spam. For example:

[0,0,1,0,0,0.5,0,...]
[0,0,0,0,1,0,0,...]
[0,0,1,0,0,0,0,...]

Can you tell what button was pressed? Maybe, but only after straining your eyes and missing a few inputs. So, no, debugging doesn’t come easily when it comes to reading inputs.

Problem 3: Lack Of Structure

Even if you throw together a quick visualizer, styles can quickly get messy. Default, active, and debug states can overlap, and without a clear structure, your CSS becomes brittle and hard to extend.

CSS Cascade Layers can help. They group styles into “layers” that are ordered by priority, so you stop fighting specificity and guessing, “Why isn’t my debug style showing?” Instead, you maintain separate concerns:

  • Base: The controller’s standard, initial appearance.
  • Active: Highlights for pressed buttons and moved sticks.
  • Debug: Overlays for developers (e.g., numeric readouts, guides, and so on).

If we were to define layers in CSS according to this, we’d have:

/* lowest to highest priority */
@layer base, active, debug;

@layer base {
  /* ... */
}

@layer active {
  /* ... */
}

@layer debug {
  /* ... */
}

Because each layer stacks predictably, you always know which rules win. That predictability makes debugging not just easier, but actually manageable.

We’ve covered the problem (invisible, messy input) and the approach (a visual debugger built with Cascade Layers). Now we’ll walk through the step-by-step process to build the debugger.

The Debugger Concept

The easiest way to make hidden input visible is to just draw it on the screen. That’s what this debugger does. Buttons, triggers, and joysticks all get a visual.

  • Press A: A circle lights up.
  • Nudge the stick: The circle slides around.
  • Pull a trigger halfway: A bar fills halfway.

Now you’re not staring at 0s and 1s, but actually watching the controller react live.

Of course, once you start piling on states like default, pressed, debug info, maybe even a recording mode, the CSS starts getting larger and more complex. That’s where cascade layers come in handy. Here’s a stripped-down example:

@layer base {
  .button {
    background: #222;
    border-radius: 50%;
    width: 40px;
    height: 40px;
  }
}
 
@layer active {
  .button.pressed {
    background: #0f0; /* bright green */
  }
}
 
@layer debug {
  .button::after {
    content: attr(data-value);
    font-size: 12px;
    color: #fff;
  }
}

The layer order matters: baseactivedebug.

  • base draws the controller.
  • active handles pressed states.
  • debug throws on overlays.

Breaking it up like this means you’re not fighting weird specificity wars. Each layer has its place, and you always know what wins.

Building It Out

Let’s get something on screen first. It doesn’t need to look good — just needs to exist so we have something to work with.










Debugger inactive