{"id":1111,"date":"2025-11-14T12:00:00","date_gmt":"2025-11-14T13:00:00","guid":{"rendered":"https:\/\/computercoursesonline.com\/?p=1111"},"modified":"2025-11-20T20:58:05","modified_gmt":"2025-11-20T20:58:05","slug":"css-gamepad-api-visual-debugging-with-css-layers","status":"publish","type":"post","link":"https:\/\/computercoursesonline.com\/index.php\/2025\/11\/14\/css-gamepad-api-visual-debugging-with-css-layers\/","title":{"rendered":"CSS Gamepad API Visual Debugging With CSS Layers"},"content":{"rendered":"

CSS Gamepad API Visual Debugging With CSS Layers<\/title><\/p>\n<article>\n<header>\n<h1>CSS Gamepad API Visual Debugging With CSS Layers<\/h1>\n<address>Godstime Aburu<\/address>\n<p> 2025-11-14T13:00:00+00:00<br \/>\n 2025-11-20T20:32:52+00:00<br \/>\n <\/header>\n<p>When you plug in a controller, you mash buttons, move the sticks, pull the triggers\u2026 and as a developer, you see none of it. The browser\u2019s picking it up, sure, but unless you\u2019re logging numbers in the console, it\u2019s invisible. That\u2019s the headache with the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Gamepad_API\">Gamepad API<\/a>.<\/p>\n<p>It\u2019s been around for years, and it\u2019s actually pretty powerful. You can read buttons, sticks, triggers, the works. But most people don\u2019t touch it. Why? Because there\u2019s no feedback. No panel in developer tools. No clear way to know if the controller\u2019s even doing what you think. It feels like flying blind.<\/p>\n<p>That bugged me enough to build a little tool: <strong>Gamepad Cascade Debugger<\/strong>. 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 <a href=\"https:\/\/www.smashingmagazine.com\/2022\/01\/introduction-css-cascade-layers\/\">CSS Cascade Layers<\/a>, the styles stay organized, so it\u2019s cleaner to debug.<\/p>\n<p>In this post, I\u2019ll 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.<\/p>\n<figure class=\"video-embed-container\">\n<div class=\"video-embed-container--wrapper\"><\/div><figcaption>Live Demo of the Gamepad Debugger showing recording, exporting, and ghost replay in action.<\/figcaption><\/figure>\n<p>By the end, you\u2019ll know how to:<\/p>\n<ul>\n<li>Spot the tricky parts of debugging controller input.<\/li>\n<li>Use Cascade Layers to tame messy CSS.<\/li>\n<li>Build a live Gamepad debugger.<\/li>\n<li>Add extra functionalities like recording, replaying, and taking snapshots.<\/li>\n<\/ul>\n<p>Alright, let\u2019s dive in.<\/p>\n<div data-audience=\"non-subscriber\" data-remove=\"true\" class=\"feature-panel-container\">\n<aside class=\"feature-panel\">\n<div class=\"feature-panel-left-col\">\n<div class=\"feature-panel-description\">\n<p>Meet <strong><a data-instant href=\"https:\/\/www.smashingconf.com\/online-workshops\/\">Smashing Workshops<\/a><\/strong> on <strong>front-end, design & UX<\/strong>, with practical takeaways, live sessions, <strong>video recordings<\/strong> and a friendly Q&A. With Brad Frost, St\u00e9ph Walter and <a href=\"https:\/\/smashingconf.com\/online-workshops\/workshops\">so many others<\/a>.<\/p>\n<p><a data-instant href=\"smashing-workshops\" class=\"btn btn--green btn--large\">Jump to the workshops \u21ac<\/a><\/div>\n<\/div>\n<div class=\"feature-panel-right-col\"><a data-instant href=\"smashing-workshops\" class=\"feature-panel-image-link\"><\/p>\n<div class=\"feature-panel-image\">\n<img loading=\"lazy\" class=\"feature-panel-image-img lazyload\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Feature Panel\" width=\"257\" height=\"355\" data-src=\"\/images\/smashing-cat\/cat-scubadiving-panel.svg\"><\/p>\n<\/div>\n<p><\/a>\n<\/div>\n<\/aside>\n<\/div>\n<h2 id=\"why-debugging-gamepad-input-is-hard\">Why Debugging Gamepad Input Is Hard<\/h2>\n<p>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:<\/p>\n<ul>\n<li>Did they press <code>A<\/code> or <code>B<\/code>?<\/li>\n<li>Is the joystick tilted halfway or fully?<\/li>\n<li>How hard is the trigger pulled?<\/li>\n<\/ul>\n<p>The <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Gamepad_API\">Gamepad API<\/a> exposes and displays all of the information you need, but only as arrays of numbers. Each button has a value (e.g., <code>0<\/code> for not pressed, <code>1<\/code> for fully pressed, and decimals for pressure-sensitive triggers), and each joystick reports its position on the X and Y axes.<\/p>\n<p>Here\u2019s what it looks like in raw form:<\/p>\n<pre><code class=\"language-css\">\/\/ Example: Reading the first connected gamepad\nconst gamepad = navigator.getGamepads()[0];\n \nconsole.log(gamepad.buttons.map(b => b.value));\n\/\/ [0, 0, 1, 0, 0, 0.5, 0, ...]\n \nconsole.log(gamepad.axes);\n\/\/ [-0.24, 0.98, -0.02, 0.00]\n <\/code><\/pre>\n<p>Is it useful? Technically, yes. Easy to debug? Not at all.<\/p>\n<h3 id=\"problem-1-invisible-state\">Problem 1: Invisible State<\/h3>\n<p>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.<\/p>\n<p>To illustrate the difference, here\u2019s how other input methods give you immediate feedback:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-css\">\/\/ Keyboard events are visible and easy to track\ndocument.addEventListener('keydown', (e) => {\n console.log('Key pressed:', e.key);\n \/\/ Outputs: \"Key pressed: a\"\n \/\/ You can see this in DevTools, and many tools show keyboard input\n});\n\n\/\/ Mouse clicks provide clear event data\ndocument.addEventListener('click', (e) => {\n console.log('Clicked at:', e.clientX, e.clientY);\n \/\/ Outputs: \"Clicked at: 245, 389\"\n \/\/ Visual feedback is immediate\n});\n\n\/\/ But gamepad input? Silent and invisible.\nconst gamepad = navigator.getGamepads()[0];\nif (gamepad) {\n console.log(gamepad.buttons[0]); \n \/\/ Outputs: GamepadButton {pressed: false, touched: false, value: 0}\n \/\/ No events, no DevTools panel, just polling\n}\n<\/code><\/pre>\n<\/div>\n<p>The gamepad doesn\u2019t fire events when buttons are pressed. You have to constantly poll it using <code>requestAnimationFrame<\/code>, checking values manually. There\u2019s no built-in visualization, no dev tools integration, nothing.<\/p>\n<p>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.<\/p>\n<h3 id=\"problem-2-too-many-inputs\">Problem 2: Too Many Inputs<\/h3>\n<p>A modern controller can have up to 15+ buttons and 4+ axes. That\u2019s over a dozen values updating at once.<\/p>\n<figure class=\"\n \n break-out article__image\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/1-xbox-playstation.jpeg\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"500\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Xbox vs. PlayStation\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/1-xbox-playstation.jpeg\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Both Xbox and PlayStation controllers pack 15+ buttons each, and they\u2019re laid out differently. Debugging across platforms means handling all that variety. (<a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/1-xbox-playstation.jpeg\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>Even if you are able to log them all, you\u2019ll quickly end up with unreadable console spam. For example:<\/p>\n<pre><code class=\"language-javascript\">[0,0,1,0,0,0.5,0,...]\n[0,0,0,0,1,0,0,...]\n[0,0,1,0,0,0,0,...]\n<\/code><\/pre>\n<p>Can you tell what button was pressed? Maybe, but only after straining your eyes and missing a few inputs. So, no, debugging doesn\u2019t come easily when it comes to reading inputs.<\/p>\n<h3 id=\"problem-3-lack-of-structure\">Problem 3: Lack Of Structure<\/h3>\n<p>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.<\/p>\n<p><a href=\"https:\/\/www.smashingmagazine.com\/2022\/01\/introduction-css-cascade-layers\/\">CSS Cascade Layers<\/a> can help. They group styles into \u201clayers\u201d that are ordered by priority, so you stop fighting specificity and guessing, <em>\u201cWhy isn\u2019t my debug style showing?\u201d<\/em> Instead, you maintain separate concerns:<\/p>\n<ul>\n<li><strong>Base<\/strong>: The controller\u2019s standard, initial appearance.<\/li>\n<li><strong>Active<\/strong>: Highlights for pressed buttons and moved sticks.<\/li>\n<li><strong>Debug<\/strong>: Overlays for developers (e.g., numeric readouts, guides, and so on).<\/li>\n<\/ul>\n<p>If we were to define layers in CSS according to this, we\u2019d have:<\/p>\n<pre><code class=\"language-css\">\/* lowest to highest priority *\/\n@layer base, active, debug;\n\n@layer base {\n \/* ... *\/\n}\n\n@layer active {\n \/* ... *\/\n}\n\n@layer debug {\n \/* ... *\/\n}\n<\/code><\/pre>\n<p>Because each layer stacks predictably, you always know which rules win. That predictability makes debugging not just easier, but actually manageable.<\/p>\n<p>We\u2019ve covered the problem (invisible, messy input) and the approach (a visual debugger built with Cascade Layers). Now we\u2019ll walk through the step-by-step process to build the debugger.<\/p>\n<div class=\"partners__lead-place\"><\/div>\n<h2 id=\"the-debugger-concept\">The Debugger Concept<\/h2>\n<p>The easiest way to make hidden input visible is to just draw it on the screen. That\u2019s what this debugger does. Buttons, triggers, and joysticks all get a visual.<\/p>\n<ul>\n<li><strong>Press <code>A<\/code><\/strong>: A circle lights up.<\/li>\n<li><strong>Nudge the stick<\/strong>: The circle slides around.<\/li>\n<li><strong>Pull a trigger halfway<\/strong>: A bar fills halfway.<\/li>\n<\/ul>\n<p>Now you\u2019re not staring at 0s and 1s, but actually watching the controller react live.<\/p>\n<p>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\u2019s where cascade layers come in handy. Here\u2019s a stripped-down example:<\/p>\n<pre><code class=\"language-css\">@layer base {\n .button {\n background: #222;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n}\n \n@layer active {\n .button.pressed {\n background: #0f0; \/* bright green *\/\n }\n}\n \n@layer debug {\n .button::after {\n content: attr(data-value);\n font-size: 12px;\n color: #fff;\n }\n}\n<\/code><\/pre>\n<p>The layer order matters: <code>base<\/code> \u2192 <code>active<\/code> \u2192 <code>debug<\/code>.<\/p>\n<ul>\n<li><code>base<\/code> draws the controller.<\/li>\n<li><code>active<\/code> handles pressed states.<\/li>\n<li><code>debug<\/code> throws on overlays.<\/li>\n<\/ul>\n<p>Breaking it up like this means you\u2019re not fighting weird specificity wars. Each layer has its place, and you always know what wins.<\/p>\n<h2 id=\"building-it-out\">Building It Out<\/h2>\n<p>Let\u2019s get something on screen first. It doesn\u2019t need to look good — just needs to exist so we have something to work with.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-html\"><h1>Gamepad Cascade Debugger<\/h1>\n\n<!-- Main controller container -->\n<div id=\"controller\">\n <!-- Action buttons -->\n <div id=\"btn-a\" class=\"button\">A<\/div>\n <div id=\"btn-b\" class=\"button\">B<\/div>\n <div id=\"btn-x\" class=\"button\">X<\/div>\n \n <!-- Pause\/menu button (represented as two bars) -->\n <div>\n <div id=\"pause1\" class=\"pause\"><\/div>\n <div id=\"pause2\" class=\"pause\"><\/div>\n <\/div>\n<\/div>\n\n<!-- Toggle button to start\/stop the debugger -->\n<button id=\"toggle\">Toggle Debug<\/button>\n\n<!-- Status display for showing which buttons are pressed -->\n<div id=\"status\">Debugger inactive<\/div>\n\n<script src=\"script.js\"><\/script>\n<\/code><\/pre>\n<\/div>\n<p>That\u2019s literally just boxes. Not exciting yet, but it gives us handles to grab later with CSS and JavaScript.<\/p>\n<p>Okay, I\u2019m using cascade layers here because it keeps stuff organized once you add more states. Here\u2019s a rough pass:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-css\">\/* ===================================\n CASCADE LAYERS SETUP\n Order matters: base \u2192 active \u2192 debug\n =================================== *\/\n\n\/* Define layer order upfront *\/\n@layer base, active, debug;\n\n\/* Layer 1: Base styles - default appearance *\/\n@layer base {\n .button {\n background: #333;\n border-radius: 50%;\n width: 70px;\n height: 70px;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n \n .pause {\n width: 20px;\n height: 70px;\n background: #333;\n display: inline-block;\n }\n}\n\n\/* Layer 2: Active states - handles pressed buttons *\/\n@layer active {\n .button.active {\n background: #0f0; \/* Bright green when pressed *\/\n transform: scale(1.1); \/* Slightly enlarges the button *\/\n }\n \n .pause.active {\n background: #0f0;\n transform: scaleY(1.1); \/* Stretches vertically when pressed *\/\n }\n}\n\n\/* Layer 3: Debug overlays - developer info *\/\n@layer debug {\n .button::after {\n content: attr(data-value); \/* Shows the numeric value *\/\n font-size: 12px;\n color: #fff;\n }\n}\n<\/code><\/pre>\n<\/div>\n<p>The beauty of this approach is that each layer has a clear purpose. The <code>base<\/code> layer can never override <code>active,<\/code> and <code>active<\/code> can never override <code>debug<\/code>, regardless of specificity. This eliminates the CSS specificity wars that usually plague debugging tools.<\/p>\n<p>Now it looks like some clusters are sitting on a dark background. Honestly, not too bad.<\/p>\n<figure class=\"\n \n break-out article__image\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/2-debugger-initial-state.png\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"402\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"The debugger\u2019s initial state showing the button layout (A, B, X, and pause bars)\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/2-debugger-initial-state.png\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n (<a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/2-debugger-initial-state.png\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<h3 id=\"adding-the-javascript\">Adding the JavaScript<\/h3>\n<p>JavaScript time. This is where the controller actually does something. We\u2019ll build this step by step.<\/p>\n<h4 id=\"step-1-set-up-state-management\">Step 1: Set Up State Management<\/h4>\n<p>First, we need variables to track the debugger\u2019s state:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ STATE MANAGEMENT\n\/\/ ===================================\n\nlet running = false; \/\/ Tracks whether the debugger is active\nlet rafId; \/\/ Stores the requestAnimationFrame ID for cancellation\n<\/code><\/pre>\n<p>These variables control the animation loop that continuously reads gamepad input.<\/p>\n<h4 id=\"step-2-grab-dom-references\">Step 2: Grab DOM References<\/h4>\n<p>Next, we get references to all the HTML elements we\u2019ll be updating:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ DOM ELEMENT REFERENCES\n\/\/ ===================================\n\nconst btnA = document.getElementById(\"btn-a\");\nconst btnB = document.getElementById(\"btn-b\");\nconst btnX = document.getElementById(\"btn-x\");\nconst pause1 = document.getElementById(\"pause1\");\nconst pause2 = document.getElementById(\"pause2\");\nconst status = document.getElementById(\"status\");\n<\/code><\/pre>\n<p>Storing these references up front is more efficient than querying the DOM repeatedly.<\/p>\n<h4 id=\"step-3-add-keyboard-fallback\">Step 3: Add Keyboard Fallback<\/h4>\n<p>For testing without a physical controller, we\u2019ll map keyboard keys to buttons:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ KEYBOARD FALLBACK (for testing without a controller)\n\/\/ ===================================\n\nconst keyMap = {\n \"a\": btnA,\n \"b\": btnB,\n \"x\": btnX,\n \"p\": [pause1, pause2] \/\/ 'p' key controls both pause bars\n};\n<\/code><\/pre>\n<p>This lets us test the UI by pressing keys on a keyboard.<\/p>\n<h4 id=\"step-4-create-the-main-update-loop\">Step 4: Create The Main Update Loop<\/h4>\n<p>Here\u2019s where the magic happens. This function runs continuously and reads gamepad state:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ MAIN GAMEPAD UPDATE LOOP\n\/\/ ===================================\n\nfunction updateGamepad() {\n \/\/ Get all connected gamepads\n const gamepads = navigator.getGamepads();\n if (!gamepads) return;\n\n \/\/ Use the first connected gamepad\n const gp = gamepads[0];\n\n if (gp) {\n \/\/ Update button states by toggling the \"active\" class\n btnA.classList.toggle(\"active\", gp.buttons[0].pressed);\n btnB.classList.toggle(\"active\", gp.buttons[1].pressed);\n btnX.classList.toggle(\"active\", gp.buttons[2].pressed);\n\n \/\/ Handle pause button (button index 9 on most controllers)\n const pausePressed = gp.buttons[9].pressed;\n pause1.classList.toggle(\"active\", pausePressed);\n pause2.classList.toggle(\"active\", pausePressed);\n\n \/\/ Build a list of currently pressed buttons for status display\n let pressed = [];\n gp.buttons.forEach((btn, i) => {\n if (btn.pressed) pressed.push(\"Button \" + i);\n });\n\n \/\/ Update status text if any buttons are pressed\n if (pressed.length > 0) {\n status.textContent = \"Pressed: \" + pressed.join(\", \");\n }\n }\n\n \/\/ Continue the loop if debugger is running\n if (running) {\n rafId = requestAnimationFrame(updateGamepad);\n }\n}\n<\/code><\/pre>\n<p>The <code>classList.toggle()<\/code> method adds or removes the <code>active<\/code> class based on whether the button is pressed, which triggers our CSS layer styles.<\/p>\n<h4 id=\"step-5-handle-keyboard-events\">Step 5: Handle Keyboard Events<\/h4>\n<p>These event listeners make the keyboard fallback work:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ KEYBOARD EVENT HANDLERS\n\/\/ ===================================\n\ndocument.addEventListener(\"keydown\", (e) => {\n if (keyMap[e.key]) {\n \/\/ Handle single or multiple elements\n if (Array.isArray(keyMap[e.key])) {\n keyMap[e.key].forEach(el => el.classList.add(\"active\"));\n } else {\n keyMap[e.key].classList.add(\"active\");\n }\n status.textContent = \"Key pressed: \" + e.key.toUpperCase();\n }\n});\n\ndocument.addEventListener(\"keyup\", (e) => {\n if (keyMap[e.key]) {\n \/\/ Remove active state when key is released\n if (Array.isArray(keyMap[e.key])) {\n keyMap[e.key].forEach(el => el.classList.remove(\"active\"));\n } else {\n keyMap[e.key].classList.remove(\"active\");\n }\n status.textContent = \"Key released: \" + e.key.toUpperCase();\n }\n});\n<\/code><\/pre>\n<h4 id=\"step-6-add-start-stop-control\">Step 6: Add Start\/Stop Control<\/h4>\n<p>Finally, we need a way to toggle the debugger on and off:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ TOGGLE DEBUGGER ON\/OFF\n\/\/ ===================================\n\ndocument.getElementById(\"toggle\").addEventListener(\"click\", () => {\n running = !running; \/\/ Flip the running state\n\n if (running) {\n status.textContent = \"Debugger running...\";\n updateGamepad(); \/\/ Start the update loop\n } else {\n status.textContent = \"Debugger inactive\";\n cancelAnimationFrame(rafId); \/\/ Stop the loop\n }\n});\n<\/code><\/pre>\n<p>So yeah, press a button and it glows. Push the stick and it moves. That\u2019s it.<\/p>\n<p>One more thing: raw values. Sometimes you just want to see numbers, not lights.<\/p>\n<figure class=\"\n \n break-out article__image\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/3-gamepad-cascade-debugger.jpeg\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"387\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"The Gamepad Cascade Debugger in its idle state with no inputs detected (Pressed buttons: 0).\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/3-gamepad-cascade-debugger.jpeg\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n The Gamepad Cascade Debugger in its idle state with no inputs detected (Pressed buttons: 0). (<a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/3-gamepad-cascade-debugger.jpeg\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>At this stage, you should see:<\/p>\n<ul>\n<li>A simple on-screen controller,<\/li>\n<li>Buttons that react as you interact with them, and<\/li>\n<li>An optional debug readout showing pressed button indices.<\/li>\n<\/ul>\n<p>To make this less abstract, here\u2019s a quick demo of the on-screen controller reacting in real time:<\/p>\n<figure class=\"video-embed-container break-out\">\n<div class=\"video-embed-container--wrapper\"><\/div><figcaption>Live demo of the on-screen controller lighting up as buttons are pressed and released.<\/figcaption><\/figure>\n<p>That\u2019s the whole foundation. From here, we can start layering in extra stuff, like record\/replay and snapshots.<\/p>\n<div class=\"partners__lead-place\"><\/div>\n<h2 id=\"enhancements-from-toy-to-tool\">Enhancements: From Toy To Tool<\/h2>\n<p>A static visualizer is helpful, but we as developers often need more than a snapshot of the controller\u2019s state. We want history, analysis, and replay. Let\u2019s add those layers on top of our debugger.<\/p>\n<h3 id=\"1-recording-stopping-input-logs\">1. Recording & Stopping Input Logs<\/h3>\n<p>We can add two buttons:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-html\"><div class=\"controls\">\n <button id=\"start-record\" class=\"btn\">Start Recording<\/button>\n <button id=\"stop-record\" class=\"btn\" disabled>Stop Recording<\/button>\n<\/div>\n<\/code><\/pre>\n<\/div>\n<h4 id=\"step-1-set-up-recording-state\">Step 1: Set Up Recording State<\/h4>\n<p>First, let\u2019s set up the variables we need to track recordings:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ RECORDING STATE\n\/\/ ===================================\n\nlet recording = false; \/\/ Tracks if we're currently recording\nlet frames = []; \/\/ Array to store captured input frames\n\n\/\/ Get button references\nconst startBtn = document.getElementById(\"start-record\");\nconst stopBtn = document.getElementById(\"stop-record\");\n<\/code><\/pre>\n<p>The <code>frames<\/code> array will store snapshots of the gamepad state at each frame, creating a complete timeline of input.<\/p>\n<h4 id=\"step-2-handle-start-recording\">Step 2: Handle Start Recording<\/h4>\n<p>When the user clicks \u201cStart Recording,\u201d we initialize a new recording session:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ START RECORDING\n\/\/ ===================================\n\nstartBtn.addEventListener(\"click\", () => {\n frames = []; \/\/ Clear any previous recording\n recording = true;\n\n \/\/ Update UI: disable start, enable stop\n stopBtn.disabled = false;\n startBtn.disabled = true;\n\n console.log(\"Recording started...\");\n});\n<\/code><\/pre>\n<h4 id=\"step-3-handle-stop-recording\">Step 3: Handle Stop Recording<\/h4>\n<p>To stop recording, we flip the state back and re-enable the Start button:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ STOP RECORDING\n\/\/ ===================================\n\nstopBtn.addEventListener(\"click\", () => {\n recording = false;\n\n \/\/ Update UI: enable start, disable stop\n stopBtn.disabled = true;\n startBtn.disabled = false;\n\n console.log(\"Recording stopped. Frames captured:\", frames.length);\n});\n<\/code><\/pre>\n<h4 id=\"step-4-capture-frames-during-gameplay\">Step 4: Capture Frames During Gameplay<\/h4>\n<p>Finally, we need to actually capture frames during the update loop. Add this inside the <code>updateGamepad()<\/code> function:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ CAPTURE FRAMES (add this inside updateGamepad loop)\n\/\/ ===================================\n\nif (recording && gp) {\n \/\/ Store a snapshot of the current gamepad state\n frames.push({\n t: performance.now(), \/\/ Timestamp for accurate replay\n buttons: gp.buttons.map(b => ({ \n pressed: b.pressed, \n value: b.value \n })),\n axes: [...gp.axes] \/\/ Copy the axes array\n });\n}\n<\/code><\/pre>\n<p>Each frame captures the exact state of every button and joystick at that moment in time.<\/p>\n<p>Once wired up, the interface displays a simple recording panel. You get a Start button to begin logging input, while the recording state, frame count, and duration remain at zero until recording begins. The following figure shows the debugger in its initial idle state.<\/p>\n<figure class=\"\n \n break-out article__image\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/4-recording-panel.jpeg\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"533\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Recording panel in its idle state, with only the start button active\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/4-recording-panel.jpeg\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Recording panel in its idle state, with only the start button active. (<a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/4-recording-panel.jpeg\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>Now, pressing <strong>Start Recording<\/strong> logs everything until you hit <strong>Stop Recording<\/strong>.<\/p>\n<h3 id=\"2-exporting-data-to-csv-json\">2. Exporting Data to CSV\/JSON<\/h3>\n<p>Once we have a log, we\u2019ll want to save it.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-html\"><div class=\"controls\">\n <button id=\"export-json\" class=\"btn\">Export JSON<\/button>\n <button id=\"export-csv\" class=\"btn\">Export CSV<\/button>\n<\/div>\n<\/code><\/pre>\n<\/div>\n<h4 id=\"step-1-create-the-download-helper\">Step 1: Create The Download Helper<\/h4>\n<p>First, we need a helper function that handles file downloads in the browser:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ FILE DOWNLOAD HELPER\n\/\/ ===================================\n\nfunction downloadFile(filename, content, type = \"text\/plain\") {\n \/\/ Create a blob from the content\n const blob = new Blob([content], { type });\n const url = URL.createObjectURL(blob);\n\n \/\/ Create a temporary download link and click it\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = filename;\n a.click();\n\n \/\/ Clean up the object URL after download\n setTimeout(() => URL.revokeObjectURL(url), 100);\n}\n<\/code><\/pre>\n<p>This function works by creating a Blob (binary large object) from your data, generating a temporary URL for it, and programmatically clicking a download link. The cleanup ensures we don\u2019t leak memory.<\/p>\n<h4 id=\"step-2-handle-json-export\">Step 2: Handle JSON Export<\/h4>\n<p>JSON is perfect for preserving the complete data structure:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ EXPORT AS JSON\n\/\/ ===================================\n\ndocument.getElementById(\"export-json\").addEventListener(\"click\", () => {\n \/\/ Check if there's anything to export\n if (!frames.length) {\n console.warn(\"No recording available to export.\");\n return;\n }\n\n \/\/ Create a payload with metadata and frames\n const payload = {\n createdAt: new Date().toISOString(),\n frames\n };\n\n \/\/ Download as formatted JSON\n downloadFile(\n \"gamepad-log.json\", \n JSON.stringify(payload, null, 2), \n \"application\/json\"\n );\n});\n<\/code><\/pre>\n<\/div>\n<p>The JSON format keeps everything structured and easily parseable, making it ideal for loading back into dev tools or sharing with teammates.<\/p>\n<h4 id=\"step-3-handle-csv-export\">Step 3: Handle CSV Export<\/h4>\n<p>For CSV exports, we need to flatten the hierarchical data into rows and columns:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ EXPORT AS CSV\n\/\/ ===================================\n\ndocument.getElementById(\"export-csv\").addEventListener(\"click\", () => {\n \/\/ Check if there's anything to export\n if (!frames.length) {\n console.warn(\"No recording available to export.\");\n return;\n }\n\n \/\/ Build CSV header row (columns for timestamp, all buttons, all axes)\n const headerButtons = frames[0].buttons.map((_, i) => `btn${i}`);\n const headerAxes = frames[0].axes.map((_, i) => `axis${i}`);\n const header = [\"t\", ...headerButtons, ...headerAxes].join(\",\") + \"n\";\n\n \/\/ Build CSV data rows\n const rows = frames.map(f => {\n const btnVals = f.buttons.map(b => b.value);\n return [f.t, ...btnVals, ...f.axes].join(\",\");\n }).join(\"n\");\n\n \/\/ Download as CSV\n downloadFile(\"gamepad-log.csv\", header + rows, \"text\/csv\");\n});\n<\/code><\/pre>\n<\/div>\n<p>CSV is brilliant for data analysis because it opens directly in Excel or Google Sheets, letting you create charts, filter data, or spot patterns visually.<\/p>\n<p>Now that the export buttons are in, you\u2019ll see two new options on the panel: <strong>Export JSON<\/strong> and <strong>Export CSV<\/strong>. JSON is nice if you want to throw the raw log back into your dev tools or poke around the structure. CSV, on the other hand, opens straight into Excel or Google Sheets so you can chart, filter, or compare inputs. The following figure shows what the panel looks like with those extra controls.<\/p>\n<figure class=\"\n \n break-out article__image\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/5-export-panel.jpeg\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"533\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Export panel with JSON and CSV buttons for saving logs\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/5-export-panel.jpeg\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Export panel with JSON and CSV buttons for saving logs. (<a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/5-export-panel.jpeg\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<h3 id=\"3-snapshot-system\">3. Snapshot System<\/h3>\n<p>Sometimes you don\u2019t need a full recording, just a quick \u201cscreenshot\u201d of input states. That\u2019s where a <strong>Take Snapshot<\/strong> button helps.<\/p>\n<pre><code class=\"language-html\"><div class=\"controls\">\n <button id=\"snapshot\" class=\"btn\">Take Snapshot<\/button>\n<\/div>\n<\/code><\/pre>\n<p>And the JavaScript:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ TAKE SNAPSHOT\n\/\/ ===================================\n\ndocument.getElementById(\"snapshot\").addEventListener(\"click\", () => {\n \/\/ Get all connected gamepads\n const pads = navigator.getGamepads();\n const activePads = [];\n \n \/\/ Loop through and capture the state of each connected gamepad\n for (const gp of pads) {\n if (!gp) continue; \/\/ Skip empty slots\n \n activePads.push({\n id: gp.id, \/\/ Controller name\/model\n timestamp: performance.now(),\n buttons: gp.buttons.map(b => ({ \n pressed: b.pressed, \n value: b.value \n })),\n axes: [...gp.axes]\n });\n }\n \n \/\/ Check if any gamepads were found\n if (!activePads.length) {\n console.warn(\"No gamepads connected for snapshot.\");\n alert(\"No controller detected!\");\n return;\n }\n \n \/\/ Log and notify user\n console.log(\"Snapshot:\", activePads);\n alert(`Snapshot taken! Captured ${activePads.length} controller(s).`);\n});\n<\/code><\/pre>\n<\/div>\n<p>Snapshots freeze the exact state of your controller at one moment in time.<\/p>\n<h3 id=\"4-ghost-input-replay\">4. Ghost Input Replay<\/h3>\n<p>Now for the fun one: ghost input replay. This takes a log and plays it back visually as if a phantom player was using the controller.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-html\"><div class=\"controls\">\n <button id=\"replay\" class=\"btn\">Replay Last Recording<\/button>\n<\/div>\n<\/code><\/pre>\n<\/div>\n<p>JavaScript for replay:<\/p>\n<pre><code class=\"language-javascript\">\/\/ ===================================\n\/\/ GHOST REPLAY\n\/\/ ===================================\n\ndocument.getElementById(\"replay\").addEventListener(\"click\", () => {\n \/\/ Ensure we have a recording to replay\n if (!frames.length) {\n alert(\"No recording to replay!\");\n return;\n }\n \n console.log(\"Starting ghost replay...\");\n \n \/\/ Track timing for synced playback\n let startTime = performance.now();\n let frameIndex = 0;\n \n \/\/ Replay animation loop\n function step() {\n const now = performance.now();\n const elapsed = now - startTime;\n \n \/\/ Process all frames that should have occurred by now\n while (frameIndex < frames.length && frames[frameIndex].t <= elapsed) {\n const frame = frames[frameIndex];\n \n \/\/ Update UI with the recorded button states\n btnA.classList.toggle(\"active\", frame.buttons[0].pressed);\n btnB.classList.toggle(\"active\", frame.buttons[1].pressed);\n btnX.classList.toggle(\"active\", frame.buttons[2].pressed);\n \n \/\/ Update status display\n let pressed = [];\n frame.buttons.forEach((btn, i) => {\n if (btn.pressed) pressed.push(\"Button \" + i);\n });\n if (pressed.length > 0) {\n status.textContent = \"Ghost: \" + pressed.join(\", \");\n }\n \n frameIndex++;\n }\n \n \/\/ Continue loop if there are more frames\n if (frameIndex < frames.length) {\n requestAnimationFrame(step);\n } else {\n console.log(\"Replay finished.\");\n status.textContent = \"Replay complete\";\n }\n }\n \n \/\/ Start the replay\n step();\n});\n<\/code><\/pre>\n<p>To make debugging a bit more hands-on, I added a ghost replay. Once you\u2019ve recorded a session, you can hit replay and watch the UI act it out, almost like a phantom player is running the pad. A new <strong>Replay Ghost<\/strong> button shows up in the panel for this.<\/p>\n<figure class=\"\n \n break-out article__image\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/6-ghost-replay-mode.jpeg\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"533\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Ghost replay mode with a session playing back on the debugger.\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/6-ghost-replay-mode.jpeg\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Ghost replay mode with a session playing back on the debugger. (<a href=\"https:\/\/files.smashing.media\/articles\/css-gamepad-api-visual-debugging-css-layers\/6-ghost-replay-mode.jpeg\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>Hit <strong>Record<\/strong>, mess around with the controller a bit, stop, then replay. The UI just echoes everything you did, like a ghost following your inputs.<\/p>\n<p>Why bother with these extras?<\/p>\n<ul>\n<li><strong>Recording\/export<\/strong> makes it easy for testers to show exactly what happened.<\/li>\n<li><strong>Snapshots<\/strong> freeze a moment in time, super useful when you\u2019re chasing odd bugs.<\/li>\n<li><strong>Ghost replay<\/strong> is great for tutorials, accessibility checks, or just comparing control setups side by side.<\/li>\n<\/ul>\n<p>At this point, it\u2019s not just a neat demo anymore, but something you could actually put to work.<\/p>\n<h2 id=\"real-world-use-cases\">Real-World Use Cases<\/h2>\n<p>Now we\u2019ve got this debugger that can do a lot. It shows live input, records logs, exports them, and even replays stuff. But the real question is: who actually cares? Who\u2019s this useful for?<\/p>\n<h3 id=\"game-developers\">Game Developers<\/h3>\n<p>Controllers are part of the job, but debugging them? Usually a pain. Imagine you\u2019re testing a fighting game combo, like <code>\u2193 \u2192<\/code> + <code>punch<\/code>. Instead of praying, you pressed it the same way twice, you record it once, and replay it. Done. Or you swap <code>JSON<\/code> logs with a teammate to check if your multiplayer code reacts the same on their machine. That\u2019s huge.<\/p>\n<h3 id=\"accessibility-practitioners\">Accessibility Practitioners<\/h3>\n<p>This one\u2019s close to my heart. Not everyone plays with a \u201cstandard\u201d controller. Adaptive controllers throw out weird signals sometimes. With this tool, you can see exactly what\u2019s happening. Teachers, researchers, whoever. They can grab logs, compare them, or replay inputs side-by-side. Suddenly, invisible stuff becomes obvious.<\/p>\n<h3 id=\"quality-assurance-testing\">Quality Assurance Testing<\/h3>\n<p>Testers usually write notes like \u201cI mashed buttons here and it broke.\u201d Not very helpful. Now? They can capture the exact presses, export the log, and send it off. No guessing.<\/p>\n<h3 id=\"educators\">Educators<\/h3>\n<p>If you\u2019re making tutorials or YouTube vids, ghost replay is gold. You can literally say, \u201cHere\u2019s what I did with the controller,\u201d while the UI shows it happening. Makes explanations way clearer.<\/p>\n<h3 id=\"beyond-games\">Beyond Games<\/h3>\n<p>And yeah, this isn\u2019t just about games. People have used controllers for robots, art projects, and accessibility interfaces. Same issue every time: what is the browser actually seeing? With this, you don\u2019t have to guess.<\/p>\n<h2 id=\"conclusion\">Conclusion<\/h2>\n<p>Debugging a controller input has always felt like flying blind. Unlike the DOM or CSS, there\u2019s no built-in inspector for gamepads; it\u2019s just raw numbers in the console, easily lost in the noise.<\/p>\n<p>With a few hundred lines of HTML, CSS, and JavaScript, we built something different:<\/p>\n<ul>\n<li><strong>A visual debugger<\/strong> that makes invisible inputs visible.<\/li>\n<li><strong>A layered CSS system<\/strong> that keeps the UI clean and debuggable.<\/li>\n<li><strong>A set of enhancements<\/strong> (recording, exporting, snapshots, ghost replay) that elevate it from demo to developer tool.<\/li>\n<\/ul>\n<p>This project shows how far you can go by mixing the Web Platform\u2019s power with a little creativity in CSS Cascade Layers.<\/p>\n<p>The tool I just explained in its entirety is open-source. You can <a href=\"https:\/\/github.com\/BboyGT\/gamepad-cascade-debugger\/tree\/main\/gamepad-cascade-debugger-final\">clone the GitHub repo<\/a> and try it for yourself.<\/p>\n<p>But more importantly, you can make it your own. Add your own layers. Build your own replay logic. Integrate it with your game prototype. Or even use it in ways I haven\u2019t imagined. For teaching, accessibility, or data analysis.<\/p>\n<p>At the end of the day, this isn\u2019t just about debugging gamepads. It\u2019s about <strong>shining a light on hidden inputs<\/strong>, and giving developers the confidence to work with hardware that the web still doesn\u2019t fully embrace.<\/p>\n<p>So, plug in your controller, open up your editor, and start experimenting. You might be surprised at what your browser and your CSS can truly accomplish.<\/p>\n<div class=\"signature\">\n <img src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Smashing Editorial\" width=\"35\" height=\"46\" loading=\"lazy\" class=\"lazyload\" data-src=\"https:\/\/www.smashingmagazine.com\/images\/logo\/logo--red.png\"><br \/>\n <span>(gg, yk)<\/span>\n<\/div>\n<\/article>\n","protected":false},"excerpt":{"rendered":"<p>CSS Gamepad API Visual Debugging With CSS Layers CSS Gamepad API Visual Debugging With CSS Layers Godstime Aburu 2025-11-14T13:00:00+00:00 2025-11-20T20:32:52+00:00 When you plug in a controller, you mash buttons, move the sticks, pull the triggers\u2026 and as a developer, you see none of it. The browser\u2019s picking it up, sure, but unless you\u2019re logging numbers…<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":[],"categories":[11],"tags":[],"_links":{"self":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1111"}],"collection":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/comments?post=1111"}],"version-history":[{"count":1,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1111\/revisions"}],"predecessor-version":[{"id":1112,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1111\/revisions\/1112"}],"wp:attachment":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/media?parent=1111"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/categories?post=1111"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/tags?post=1111"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}