USB Game Controller
Works with
Any CircuitPython board with native USB and analog inputs — Feather M0/M4, ItsyBitsy M0/M4, ItsyBitsy RP2040, Feather RP2040
Libraries used: adafruit_hid · adafruit_debouncer · analogio
What you will build
A fully custom USB gamepad with a two-axis thumbstick and four buttons. Plug it into any computer and the OS recognizes it instantly as a standard HID gamepad — no drivers, no setup, no configuration. Open a browser game, go to the controller settings, and your axes and buttons are already there.
This is a real gamepad in the sense that matters: Windows, macOS, and Linux all see it as a legitimate input device, calibrate it automatically, and pass its events to any application. You built the hardware, you wrote the firmware, and it just works.
Parts list
| Part | Notes |
|---|---|
| CircuitPython board with native USB + 2 analog inputs | Feather M4 Express or ItsyBitsy M4 recommended |
| 2-axis thumbstick module | Generic PS2-style, 3.3V compatible |
| Momentary push buttons (×4) | 6mm tactile or arcade-style |
| 10 kΩ resistors (×4) | Pull-downs for buttons (or use internal pull-ups) |
| Breadboard + jumper wires | |
| USB cable |
Wiring
graph TD
MCU["CircuitPython Board\n(Feather M4 / ItsyBitsy M4)"]
STICK["Thumbstick Module\n(PS2-style)"]
B1["Button 1\n(Cross / A)"]
B2["Button 2\n(Circle / B)"]
B3["Button 3\n(Square / X)"]
B4["Button 4\n(Triangle / Y)"]
MCU -- "3.3V" --> STICK
MCU -- "GND" --> STICK
MCU -- "A0 (VRx)" --> STICK
MCU -- "A1 (VRy)" --> STICK
MCU -- "D2 (with pull-up)" --> B1
B1 -- "GND" --> MCU
MCU -- "D3 (with pull-up)" --> B2
B2 -- "GND" --> MCU
MCU -- "D4 (with pull-up)" --> B3
B3 -- "GND" --> MCU
MCU -- "D5 (with pull-up)" --> B4
B4 -- "GND" --> MCU
Tip
Use digitalio.Direction.INPUT with pull=digitalio.Pull.UP on each button pin — then button pressed = False. No external resistors needed.
Complete code
import time
import board
import analogio
import digitalio
import usb_hid
from adafruit_hid.gamepad import Gamepad
from adafruit_debouncer import Debouncer
# --- Gamepad HID device ---
gp = Gamepad(usb_hid.devices)
# --- Thumbstick (analog) ---
axis_x = analogio.AnalogIn(board.A0)
axis_y = analogio.AnalogIn(board.A1)
# --- Buttons (digital with debouncer) ---
BUTTON_PINS = [board.D2, board.D3, board.D4, board.D5]
raw_buttons = []
for pin in BUTTON_PINS:
p = digitalio.DigitalInOut(pin)
p.direction = digitalio.Direction.INPUT
p.pull = digitalio.Pull.UP
raw_buttons.append(p)
buttons = [Debouncer(b) for b in raw_buttons]
# HID button numbers (1-indexed for adafruit_hid)
BUTTON_NUMS = [1, 2, 3, 4]
DEADZONE = 2000 # Raw ADC units — ignore tiny stick drift near center
CENTER = 32767 # Midpoint of 16-bit ADC range
def adc_to_joystick(raw):
"""Map 0–65535 ADC reading to -127–127 joystick range with deadzone."""
offset = raw - CENTER
if abs(offset) < DEADZONE:
return 0
# Scale remaining range to -127..127
scaled = int(offset / CENTER * 127)
return max(-127, min(127, scaled))
print("USB Gamepad ready.")
while True:
# Read and map analog axes
x = adc_to_joystick(axis_x.value)
y = adc_to_joystick(axis_y.value)
gp.move_joysticks(x=x, y=y)
# Update debouncers and send button events
for i, btn in enumerate(buttons):
btn.update()
num = BUTTON_NUMS[i]
if btn.fell: # just pressed (active-low: fell = pressed)
gp.press_buttons(num)
print(f"Button {num} pressed")
elif btn.rose: # just released
gp.release_buttons(num)
print(f"Button {num} released")
time.sleep(0.01) # ~100 Hz polling rate
How it works
USB HID gamepad descriptor
When CircuitPython boots on a native-USB board, the usb_hid module advertises a set of HID descriptors to the host computer. The adafruit_hid library includes a pre-built Gamepad descriptor that declares two analog axes and eight buttons. The host OS reads this descriptor during enumeration and creates a gamepad device entry — that is why you see it in Windows Game Controllers or macOS's USB device list without installing anything. gp.move_joysticks() and gp.press_buttons() write reports into a 64-byte HID input report buffer that the OS polls at USB full-speed rates (up to 125 Hz).
Analog thumbstick reading and deadzone
analogio.AnalogIn returns a 16-bit unsigned value (0–65535) proportional to the voltage on the pin. A centered thumbstick rests near the midpoint (around 32767) but rarely sits exactly there — electrical noise and mechanical tolerance mean there is always a small drift signal even when you are not touching it. The adc_to_joystick() function subtracts the center value, ignores anything smaller than DEADZONE raw units, and then scales the remainder to the −127–127 range expected by Gamepad.move_joysticks(). Adjust DEADZONE up if the cursor drifts, down if the stick feels sluggish near center.
Debounced buttons
Mechanical buttons bounce — when the contacts close, they make and break contact dozens of times in a few milliseconds before settling. Without debouncing, a single press can register as many rapid presses. adafruit_debouncer.Debouncer wraps any digital input and requires the signal to be stable for a short window (default 10 ms) before reporting a state change. btn.fell is True for exactly one loop iteration when the button transitions from released to pressed (active-low: the pin voltage falls). btn.rose is True when it transitions back. This gives you clean, single-event press and release detection with no extra hardware.
Remix ideas
Remix idea
Add more axes and buttons. A real gamepad has triggers, a second thumbstick, bumpers, and a d-pad. The Custom HID hacker page shows you how to write your own HID descriptor with up to 8 axes and 16 buttons.
Remix idea
Control it with gestures. Replace the thumbstick with an IMU (accelerometer + gyroscope) and tilt the board to move. The Gesture Control builder covers the sensor setup — swap the joystick output code for the same gp.move_joysticks() call.
Remix idea
Go wireless. Swap to an nRF52840-based board and use BLE HID instead of USB HID. The BLE Keyboard builder covers the BLE HID stack — the button and axis reading code carries over unchanged.