Turn your smartphone into a wireless Xbox 360 gamepad for Linux PC games.
RemoteGamepad bridges a phone's browser to a virtual Xbox 360 controller on Linux. The phone reads input via the Web Gamepad API, streams it over WebSocket to a FastAPI server, and the server emits real evdev events into /dev/uinput. Games on the PC see a standard Xbox 360 pad — no game-side modifications needed.
Niche it solves: situations where a controller won't connect directly to the PC (missing drivers, Bluetooth pairing issues, hardware quirks) but the phone can see it. Or no physical pad at all — drive the virtual one with the on-screen touch UI.
- Faithful Xbox 360 impersonation. The virtual device registers in the kernel as
Microsoft X-Box 360 padwith the genuine vendor/product IDs (0x045e:0x028e). Steam, SDL2 and in-game mapping libraries automatically pick the proper Xbox profile instead of falling back to a generic pad with broken triggers. - Triggers behave as axes and buttons.
LT/RTare sent both asABS_Z/ABS_RZaxes (0–255) and asBTN_TL2/BTN_TR2button events (button threshold > 25/255). Different testers and games read different forms — this hits both. - Async over a single WebSocket. One
/wsendpoint, one JSON frame carrying sticks + buttons + D-pad. Latency on a LAN is dominated by the radio link, not the server. - Console QR codes. On startup
main.pyprints an ASCII QR for the LAN URL. IfEXTERNAL_URLis set (zrok / ngrok / any tunnel), a second QR for the public address is printed alongside. Aim the camera, the UI opens, play. - Explicit
AbsInfo. Sticks are 16-bit signed (-32768..32767), triggers 8-bit unsigned (0..255), D-pad is a hat switch (-1..1),fuzzandflatare zero. Sidesteps the floating dead zones you get from kernel defaults that vary across distros.
| Layer | Technology |
|---|---|
| HTTP / WebSocket server | FastAPI + uvicorn[standard] (async/await) |
| Virtual gamepad | python-evdev → /dev/uinput |
| Frontend | Web Gamepad API + HTMX + Materialize CSS |
| Templates | Jinja2 (templates/index.html) |
| Config | .env (PORT, EXTERNAL_URL) |
| Logging | Loguru |
| QR | qrcode[pil] — ASCII console output |
| i18n | lang/{en,ru}.json + HTMX language switcher |
| Dependency manager | uv (pyproject.toml + uv.lock) |
| Language | Python 3.13 |
| License | GPL-3.0 |
┌──────────────┐ USB/BT/OTG ┌──────────┐ WebSocket ┌─────────────┐ evdev ┌────────┐
│ Physical │ ──────────▶ │ Phone │ ─────────▶ │ FastAPI │ ───────▶ │ Linux │
│ controller │ │ (Browser │ JSON over │ /ws + / │ uinput │ kernel │
│ (opt.) │ │ Gamepad │ local WiFi│ (uvicorn) │ │ │
└──────────────┘ │ API) │ └─────────────┘ └────┬───┘
└──────────┘ │
▲ ▼
│ touch UI ┌─────────────┐
│ (no physical │ Any game │
│ pad case) │ (SDL2/Steam)│
└─────────────┘
- Phone collects input — either from a physical pad via
navigator.getGamepads(), or via the on-screen touch UI. - The client serialises
{axes, buttons}to JSON and pushes it through an open WebSocket. - FastAPI demultiplexes: axes →
EV_ABS, buttons →EV_KEY, D-pad → hat axes. - evdev writes to
/dev/uinput; the Linux kernel exposes a real input device. - Games see a regular Xbox 360 controller.
- Linux (any modern distro —
/dev/uinputships with the mainline kernel) - Python 3.13+
uv— dependency manager:curl -LsSf https://astral.sh/uv/install.sh | sh- A smartphone or tablet with a modern browser on the same Wi-Fi
# 1. Clone
git clone https://github.com/ZenonEl/RemoteGamepad.git
cd RemoteGamepad
# 2. One-time: grant your user permission to create virtual input devices
sudo bash scripts/setup_udev.sh
# Re-login once after this — the input group only applies to a new session.
# 3. Install dependencies (uv reads pyproject.toml + uv.lock)
uv sync
# 4. (Optional) configure a tunnel
cp .env.example .env
# Edit .env: EXTERNAL_URL is only needed when exposing the server via zrok/ngrok.
# 5. Run
uv run main.pyThe server prints a QR code in the console pointing at http://<your-LAN-IP>:5002. Scan it with your phone — the touch UI opens. If a physical controller is attached to the phone, it Just Works; otherwise the on-screen controls drive the virtual pad.
RemoteGamepad/
├── main.py # entry point — QR codes + uvicorn
├── pyproject.toml # uv-managed deps
├── uv.lock
├── .env.example
├── scripts/setup_udev.sh # uinput permissions
├── src/
│ ├── config.py # PORT, EXTERNAL_URL
│ ├── api/server.py # FastAPI: GET /, WS /ws, static mount
│ └── core/
│ ├── gamepad_manager.py # VirtualGamepadDevice + manager
│ └── mapping_config.py # JS button names → evdev keycodes
├── templates/
│ ├── index.html # main UI
│ └── translations.html
├── static/ # JS / CSS
├── lang/{en,ru}.json # i18n strings
└── assets/ # icons, images
Permission denied on /dev/uinput — scripts/setup_udev.sh wasn't run, or there was no re-login afterwards. The script creates a udev rule granting the input group write access to uinput, adds your user to input, and loads the uinput kernel module. Group membership only applies to a fresh session.
Phone can't reach http://<LAN-IP>:5002 — the port is blocked by the firewall.
firewalld:sudo firewall-cmd --add-port=5002/tcp --permanent && sudo firewall-cmd --reloadufw:sudo ufw allow 5002/tcp- Sanity-check on the PC:
curl -I http://localhost:5002.
Buttons fire in the browser but the game maps them wrong — make sure the game uses SDL2 mappings (most modern engines do). Verify the device shows up as Xbox 360 in the kernel: cat /proc/bus/input/devices | grep -A4 "X-Box 360".
Want it reachable from outside the LAN? Run zrok or ngrok pointing at localhost:5002, drop the public URL into .env as EXTERNAL_URL, restart. A second QR is printed for that URL.
- Beta. The wire protocol and mappings may change between commits.
- One virtual gamepad per server instance — the manager is single-slot today (KISS).
- Linux only. Coupled to
/dev/uinput; Windows / macOS would need a different virtual-driver backend. - No authentication. Anyone on the same Wi-Fi who knows the URL can drive the pad. Keep it on a trusted network or behind a tunnel that does auth.
- No automated tests in this branch yet.
GPL-3.0 — see LICENSE.
- GitHub: @ZenonEl