Universal controller receiver and mapper running on an sbc.
Got a weird Bluetooth controller that nothing supports natively? Xbox 360 controllers work on nearly everything. So make it one.
cursed_controls runs on a Raspberry Pi Zero between your controllers and the host, emulating a real Xbox 360 wireless receiver over USB OTG. Combine multiple physical devices into a single virtual Xbox pad (think Wii Remote + Nunchuk), with up to 4 controller slots. Works on Windows and Linux; macOS might limited to 1 slot on older versions.
I initially wanted to make this, as some games (especially in unity) can be super finicky about the controllers connected. e.g. even a wireless xbox controller can sometimes cause weird mappings and other issues. In these cases it's useful to 'act' as a wired controller, while still having the benefit of being wireless :D. This completely circumvents having to do painful config on the host/gaming machine where you just want to play. Instead you just let the pi handle it. This is also useful for when you just want to switch between multiple devices without having to pair your BT controllers CONSTANTLY.
Tested on Raspberry Pi Zero W and Pi Zero 2W.
Small disclaimer: The hard part of initially making it work was done by hand. However, to finish this project and make it useful, this was partially vibe coded (yes I know, very bad. But finished is better than never seeing the light of day).
This is a visual mock of the web UI for quick exploration. It does not connect to hardware, Bluetooth, APIs, or the live backend.
- Physical controllers connect to the Pi (Bluetooth, USB, etc.) and appear as evdev devices.
cursed-controlsreads their events, applies a YAML mapping config, and builds Xbox 360 HID packets.- Packets are sent to 360-w-raw-gadget (a submodule), which emulates a real Xbox 360 wireless receiver over the Pi's USB OTG port.
- Rumble commands from the host are forwarded back to any physical device that supports force feedback.
The Pi introduces roughly 10–20 ms of processing latency (evdev in → USB HID out), measured on a Pi Zero W. Combined with the Bluetooth link from the physical controller (~8–15 ms), total end-to-end latency is probably in the 20–35 ms range.
For competitive or rhythm games requiring precise timing, you might feel the difference compared to a wired controller (~4 ms). But then you're probably not using a wireless controller anyway.
- Raspberry Pi (or similar SBC) with a USB OTG port
- Python 3.11+
raw_gadgetkernel module (handled byinstall.sh)360-w-raw-gadgetbuilt as a shared library (handled byinstall.sh)- Plugging in the pi over USB (duh)
See SetupRaspbian.md for the full Pi setup guide.
curl|bash oneline installs (RUN ON PI):
One-liner (recommended):
curl -fsSL https://raw.githubusercontent.com/CasperVM/cursed_controls/main/install.sh | bashHeadless appliance — faster boot, lower idle power, no HDMI:
curl -fsSL https://raw.githubusercontent.com/CasperVM/cursed_controls/main/install.sh | bash -s -- --headless-fast-bootPrefer to inspect first?
git clone https://github.com/CasperVM/cursed_controls.git
bash ~/cursed_controls/install.sh [--headless-fast-boot]The installer is safe to re-run — each step skips if already complete.
Open the web UI from another device on the same network at the above url.
Example:
http://192.168.1.123:8000
--headless-fast-boot adds these settings to config.txt:
hdmi_blanking=1
hdmi_ignore_hotplug=1
camera_auto_detect=0
display_auto_detect=0
dtparam=audio=off
gpu_mem=16
dtparam=act_led_trigger=none
dtparam=act_led_activelow=on
It does not disable Wi-Fi or Bluetooth.
For even faster booting/more optimization, take a look at the boottime optimization doc here.
The web UI ships with ready-made presets in the presets/ folder. Apply them from the Profiles tab.
| Preset | Match name | Notes |
|---|---|---|
| Xbox Wireless Controller | Xbox Wireless Controller |
|
| PS5 DualSense | Wireless Controller |
|
| Nintendo Pro Controller | Pro Controller |
Bluetooth; see note below |
| 8BitDo Pro 3 | 8BitDo Pro 3 |
|
| Wii Remote | Nintendo Wii Remote |
Pairs with wiimote connection type |
| Wii Remote + Nunchuk | Nintendo Wii Remote / Nunchuk |
Two devices, one slot |
| Wii Remote + Nunchuk — Rocket League | Nintendo Wii Remote / Nunchuk |
Nintendo Pro Controller over Bluetooth: The
hid-nintendodriver emits IMU (gyro/accelerometer) updates at high frequency regardless of whether you use them, which can make input noticeably laggy or spammy on the Pi. Rumble also tends to work poorly over BT with this driver.
All presets were made with Bluetooth devices, this hasnt been tested on e.g. a pi4
# List detected input devices
cursed-controls list-devices
# Run with a mapping config (requires root for raw-gadget)
sudo cursed-controls run mapping.yaml
# Dry-run: print packets to stdout instead of opening gadget
cursed-controls run --stdout mapping.yaml
# Interactive simulation (no hardware needed)
cursed-controls simulate mapping.yaml
# Interactive TUI to build a new mapping file (WIP — see note below)
cursed-controls map mapping.yaml
# Live axis debug TUI: shows per-axis current value, min/max, and bar chart
# Run without arguments to get a device selection menu
python scripts/show_axis_range.py
python scripts/show_axis_range.py /dev/input/eventN # skip menuNote on
map: The interactive mapper is a work in progress. It can be finicky with drifty axes (e.g. Nunchuk joystick). For reliable results, especially when tuning axis ranges, it's often easier to edit the YAML directly. Useshow_axis_range.pyto find the real min/max values, then setsource_min/source_maxby hand.
The Raspberry Pi install uses cursed-controls-web.service, which starts the web UI on boot. From the UI, you can edit mapping.yaml and start or stop the gadget runtime.
runtime:
output_mode: gadget # or "stdout" for dry-run
gadget_library: 360-w-raw-gadget/target/release/libx360_w_raw_gadget.so
# gadget_driver is auto-detected from /sys/class/udc/ — override only if needed
interfaces: 1 # controller slots (1–4)
rumble: true # forward rumble to physical devices
devices:
- id: my-controller
connection:
type: wiimote # wiimote | bluetooth | evdev (default)
timeout_s: 60 # how long to wait/scan before giving up
match:
name: "Nintendo Wii Remote" # match by name, uniq, or phys
mappings:
# Button → button
- source_type: 1 # EV_KEY
source_code: 304 # BTN_A
target: A
kind: button
# Button → axis (e.g. trigger)
- source_type: 1
source_code: 305 # BTN_B
target: RIGHT_TRIGGER
kind: button
on_value: 255
off_value: 0
# Axis → axis (with scaling and deadzone)
- source_type: 3 # EV_ABS
source_code: 16 # ABS_HAT0X
target: LEFT_JOYSTICK_X
kind: axis
source_min: -120
source_max: 120
target_min: -32767
target_max: 32767
deadzone: 0.05
# Hat axis → d-pad button (ABS_HAT0X/Y, values -1/0/1)
- source_type: 3
source_code: 16 # ABS_HAT0X
target: DPAD_LEFT
kind: hat| type | behaviour |
|---|---|
evdev |
Device is already in /dev/input/ (default) |
bluetooth |
Connect by MAC at startup (mac: required) |
wiimote |
Scan for Nintendo Wii Remote; user presses 1+2 |
| kind | source | target | notes |
|---|---|---|---|
button |
EV_KEY or EV_ABS |
button or trigger | on_value/off_value optional |
axis |
EV_ABS |
joystick or trigger | scales source_min..max → target_min..max |
hat |
EV_ABS (-1/0/1) |
DPAD_* |
infers direction from target surface |
Buttons: A B X Y BUMPER_L BUMPER_R STICK_L STICK_R START OPTIONS XBOX DPAD_UP DPAD_DOWN DPAD_LEFT DPAD_RIGHT
Axes: LEFT_JOYSTICK_X LEFT_JOYSTICK_Y RIGHT_JOYSTICK_X RIGHT_JOYSTICK_Y LEFT_TRIGGER RIGHT_TRIGGER
example_wiimote.yaml— Wii Remote + Nunchuk (generic)example_rocket_league.yaml— Wii Remote + Nunchuk for Rocket Leagueexample_tv_remote.yaml— Wii Remote only, held vertically (navigation/media)example_xbox_passthrough.yaml— Xbox Wireless Controller passthrough
When rumble: true, the runtime polls the gadget for rumble commands from the host each tick and forwards them to any bound physical device that exposes EV_FF / FF_RUMBLE. Works with Wii Remotes (the hid-wiimote driver exposes FF).
pytest
# or with uv:
uv run pytestAll tests run without hardware (FakeSink, mock devices).