Turn your phone into a wireless steering wheel, MotoGP handlebar, and Xbox 360 controller. No app to install — the client is a single web page, and the server creates a real virtual gamepad that racing games (Forza, Assetto Corsa, MotoGP, Ride, Need for Speed, DiRT, BeamNG, etc.) already understand.
Version 2 adds native Linux support via the kernel uinput API, a background controller command, an HTTPS tunnel helper, and a much faster input path. Windows is still supported through the original vgamepad/ViGEmBus backend.
- Linux native — no ViGEmBus, no Windows driver, no extra Python
evdevpackage. It talks directly to/dev/uinput. controllercommand — starts the server in the background, prints the URL, and returns immediately. Includesstop,restart,status,logs,doctor, andtunnel.- Lower latency — input changes are sent on the next display frame (
requestAnimationFrame), WebSocket compression is off, Nagle is disabled, stale frames are not queued, and each frame is applied to the kernel as one batched write. - Crash-safe controls — a 1-second watchdog releases the throttle/steering if the phone disconnects or the screen locks.
- Game presets — Racing, MotoGP, and Cozy layouts, each with its own labels, mappings, and saved steering tune. Racing is tuned for RaceRoom track work with a 12° lock, 1.4× steering expo, and analog pressure pedals. MotoGP adds a sharp bike profile plus gyro throttle/brake for automatic-gearbox play.
- Steering tune panel — invert, sensitivity, deadzone, steering expo, analog pedals, center calibration, and MotoGP tilt-throttle range/deadzone, saved on the phone.
- Better touch handling — multi-touch, touch cancellation, and a release-all on screen blur/visibility change.
- HTTPS helper —
controller tunnel installdownloadscloudflared, thencontroller upstarts the server and a quick HTTPS tunnel. This is the reliable way to get gyroscope permission on mobile browsers.
- A Linux PC with the
uinputkernel module (standard on Debian, Ubuntu, Arch, Fedora, etc.). - Python 3.10+.
websockets14 or newer.- One-time
sudoaccess for the/dev/uinputudev rule.
- Python 3.10+.
websockets14 or newer andvgamepad.- ViGEmBus installed.
- A modern mobile browser (Safari or Chrome).
- Motion/gyroscope permission. HTTPS is required by most mobile browsers for motion sensors — see HTTPS and the gyroscope.
git clone https://github.com/devsurx/EzController.git
cd EzController
./install.sh # installs the `controller` command (no root)
controller setup # one-time: installs the uinput udev rule (asks for sudo)
controller # start the server in the backgroundcontroller setup is only needed once. It:
- Installs
/etc/udev/rules.d/99-ezcontroller.rules. - Loads the
uinputkernel module and persists it with/etc/modules-load.d/ezcontroller.conf. - Adds you to the
inputgroup if necessary.
If your user was just added to the group, log out and back in (or reboot) once.
Then open the printed Phone URL on your phone. Tap SYS.IGNITION, allow motion access, and hold the phone in landscape.
For gyro steering over HTTPS:
controller tunnel install # downloads cloudflared to ~/.local/bin (no root)
controller up # starts the server + tunnel, prints an https:// URLIf
websocketsis missing or too old,./install.shcreates a private.venvin the checkout and thecontrollercommand uses it automatically.
python3 -m pip install --user -r requirements.txt
python3 server.py # Linux uinput backend
python3 server.py --pad mock # test the protocol without a virtual padgit clone https://github.com/devsurx/EzController.git
cd EzController
pip install -r requirements-windows.txtInstall the ViGEmBus driver, then double-click racing.bat (or run python server.py). The original workflow is unchanged:
- Start the server.
- Start your tunnel (
ngrok http 5000, or usecontrollerfrom WSL/Git Bash if you prefer). - Open the HTTPS URL on your phone.
- Tap IGNITION, allow motion, and drive.
The controller shell command is Linux-oriented, but the server itself is cross-platform.
controller start in the background (default)
controller up start the server + HTTPS tunnel
controller stop stop the server and tunnel
controller down alias for stop
controller restart restart (restores the tunnel if it was running)
controller status PID, pad, URLs, client count, uptime, tunnel
controller url print the current URLs
controller logs last 60 log lines
controller logs -f follow the log
controller logs 200 last 200 lines
controller tunnel start an HTTPS tunnel (cloudflared or ngrok)
controller tunnel stop stop the tunnel
controller tunnel status show the HTTPS URL
controller tunnel install download cloudflared (no root)
controller setup install the command + one-time uinput rule
controller doctor check uinput, Python, port, module, tunnel
controller foreground run in the foreground (Ctrl+C to stop)
controller version
Logs and PID files live in ${XDG_STATE_HOME:-~/.local/state}/ezcontroller/.
| Variable | Default | Meaning |
|---|---|---|
EZC_PORT |
5000 |
HTTP/WebSocket port |
EZC_HOST |
0.0.0.0 |
Bind address |
EZC_PAD |
auto |
auto, linux, windows, or mock |
EZC_PYTHON |
auto | Python interpreter to use |
EZC_HOME |
script location | EzController checkout |
EZC_SUDO |
sudo |
Privilege command for setup (e.g. sudo -A) |
Examples:
EZC_PORT=5001 controller
EZC_PAD=mock controller foreground # protocol test, no /dev/uinput
controller doctorThere is no artificial delay anywhere in the input path:
- Phone → browser: touch and gyro events update in-memory state immediately. Normally sends are coalesced into the next animation frame; MotoGP sends every motion sample immediately so lean, throttle, and brake react without waiting for the frame. A 10 Hz heartbeat keeps held buttons alive when the gyro is still.
- Browser → server: the WebSocket uses
TCP_NODELAY; per-message compression is disabled;max_queue=1andwrite_limit=0prevent stale frames from piling up. Reconnects retry in 250 ms (backing off to 2 s). - Server: one JSON parse, one normalised state, and one batched
write()to/dev/uinputper frame, ending in a singleSYN_REPORT. Only changed axes/buttons are written. - Kernel/game: the virtual device advertises the exact Xbox 360 identity (
045e:028e, "Microsoft X-Box 360 pad"), so SDL/Steam/Proton/native games use the standard xpad mapping. Sticks are ±32767, triggers are 0–255. - No smoothing or interpolation. The server never averages or ramps values.
- Watchdog: if no valid frame arrives for 1 second (screen lock, Wi-Fi drop, browser crash), all controls are reset to neutral. On reconnect the new phone takes over and the old connection is closed.
The old 4.3-second boot animation is gone; the overlay now clears in about 0.3 s.
"0 delay" means no software-added delay. Physical Wi-Fi, display refresh, browser scheduling, and an internet tunnel still have real latency. A 5 GHz LAN connection is the fastest option; Cloudflare/ngrok tunnels add internet round-trip time.
Built for automatic gearbox bike racing. The shift buttons are removed and the brake/throttle surfaces are enlarged.
| Phone control | Xbox 360 | In MotoGP |
|---|---|---|
| Rotate/tilt phone left-right | Left stick X | Lean / steer |
| Tilt phone forward | Right trigger (analog) | Gyro throttle |
| Pull/tilt phone back | Left trigger (analog) | Gyro front brake |
| THROTTLE (right trigger) | Right trigger | Manual throttle override |
| FRONT BRAKE (left trigger) | Left trigger | Manual front-brake override |
| REAR BRAKE (centre) / A | A | Rear brake |
| B | B | Tuck |
| X / Y | X / Y | Game actions |
| Right thumbstick | Right stick | Camera |
| D-pad / View / Menu / Guide | D-pad / Back / Start / Guide | Menus |
Gyro throttle and brake use the phone's pitch axis, which is independent of the left/right lean axis, so leaning does not add throttle. Use TILT RANGE, TILT DEADZONE, and INVERT TILT in ⚙ SETTINGS. The triggers remain as analog overrides — whichever input is stronger wins.
The MotoGP tune is separate from the Racing tune: 12° lean to full lock, 0.8° lean deadzone, 20° tilt for full throttle/brake, and 1.6° tilt deadzone by default. These wider defaults keep small hand movements from slamming to full lock while remaining linear and latency-free. Its lean, throttle, and brake samples are sent immediately instead of waiting for the next animation frame.
| Phone control | Xbox 360 |
|---|---|
| Rotate/tilt phone (landscape) | Left stick / steering |
| Right thumbstick | Right stick / camera |
| GAS (right trigger) | Right trigger, analog pressure |
| BRAKE (left trigger) | Left trigger, analog pressure |
| GEAR + / GEAR − | RB / LB |
| A / B / X / Y | Face buttons |
| E-BRAKE | B |
| D-pad | D-pad |
| View / Menu / Guide | Back / Start / Guide |
The Racing pedals are analog. Press near the top of GAS or BRAKE for a light application, slide your thumb lower for more pressure, and the fill shows the current value. The pressure is held until you lift, so you can feather the throttle out of a slow corner or trail-brake into an apex. Turn ANALOG PEDALS off in ⚙ SETTINGS if you prefer simple on/off pedals.
Bind the virtual pad once in RaceRoom under Options → Controls → Controller:
| RaceRoom action | Bind to |
|---|---|
| Steering | Left Stick X |
| Throttle | Right Trigger |
| Brake | Left Trigger |
| Shift up / Shift down | RB / LB |
| Handbrake | B |
| Look / camera | Right Stick |
| Menus | D-pad / View / Menu |
Start with Steering Dead Zone 0%, Steering Sensitivity 50%, Speed Sensitive Steering 0%, and Steering Curve Exponent 1.0. The phone already applies a 0.8° deadzone and a 1.4× expo, so leaving the game's curve linear avoids stacking two curves.
Then tune to taste:
- If the car feels twitchy at speed, raise in-game Speed Sensitive Steering to 25–50% or raise the phone's SENSITIVITY slightly.
- If the car feels lazy or slow to turn in, raise in-game Steering Sensitivity first, before lowering the phone's expo.
- If you want finer corrections around straight-ahead without changing full lock, raise STEERING EXPO.
- Use CALIBRATE CENTER on the grid while holding the phone in your normal driving position, not mid-corner.
Racing also uses a light adaptive filter: it removes hand tremor at rest but lets fast corrections through untouched, so countersteer is not delayed. MotoGP remains raw and immediate.
Tap PRESET: … (or ⚙ SETTINGS) in the top-right HUD and choose a layout. The page remembers your last choice.
| Preset | Best for | Left stick | Triggers | Bumpers | Extra |
|---|---|---|---|---|---|
| Racing | RaceRoom, Forza, Assetto Corsa, DiRT, BeamNG | Gyro tilt steers, 1.4× expo | Analog gas / brake | Gear − / + | E-BRAKE, RPM HUD |
| MotoGP | MotoGP, Ride, SBK, automatic gearbox | Gyro lean (12° lock) | Gyro tilt throttle / brake + trigger overrides | Hidden | AUTO gearbox, rear brake, tilt tune |
| Cozy | Stardew Valley, 2D games | On-screen thumbstick, full X/Y | Switch item | Shift toolbar | No gyro, no e-brake |
Cozy uses the space freed by hiding the D-pad and Guide button to make the left/right sticks, ABXY face buttons, triggers, and bumpers larger — the controls you touch constantly in Stardew. The sticks also scale their throw with their on-screen size.
The Cozy LT/RT item and LB/RB toolbar buttons are flat, clicky buttons with no pedal travel or press animation. Short taps are held for a few frames on the trigger axes so the game cannot miss them between polls — tap once and the item switches immediately.
Cozy uses the standard Xbox 360 Stardew Valley mapping:
| Phone control | Xbox 360 | In Stardew Valley |
|---|---|---|
| Left thumbstick | Left stick | Walk / run |
| Right thumbstick | Right stick | Move cursor, open chat |
| A | A | Do action / interact |
| X | X | Use tool |
| Y | Y | Crafting menu |
| B | B | Cancel / menu |
| LT / RT | LT / RT | Switch item |
| LB / RB | LB / RB | Shift toolbar |
| View / Menu / Guide | Back / Start / Guide | Journal / menu / guide |
Tap ⚙ SETTINGS in the top-right HUD. Racing and MotoGP each keep their own tune:
- INVERT — flip steering/lean direction if your phone is mounted the other way.
- SENSITIVITY — total tilt for full lock (Racing default 12°, MotoGP default 12°). Increase it if the car or bike still feels twitchy.
- DEADZONE — center deadzone (Racing default 0.8°, MotoGP default 0.8°).
- STEERING EXPO (Racing) — 1.00× is linear; the default 1.40× gives finer corrections around straight-ahead while keeping the same full lock.
- ANALOG PEDALS (Racing) — on by default. Touch high on GAS/BRAKE for light pressure, low for full pressure.
- TILT RANGE (MotoGP) — forward/back tilt for full throttle or brake (default 20°).
- TILT DEADZONE (MotoGP) — ignore small pitch changes (default 1.6°).
- INVERT TILT (MotoGP) — swap gyro throttle and brake if the phone is held the other way.
- CALIBRATE CENTER — set the current lean (and MotoGP pitch) as neutral.
- RESET DEFAULTS.
Settings are stored in localStorage on the phone (ezc.settings for Racing, ezc.bike for MotoGP).
Mobile browsers restrict motion sensors to secure contexts. On plain http://192.168.x.x:5000 the buttons may work but the gyroscope can be blocked (iOS Safari is strict; Chrome behavior varies by version).
Use the tunnel:
controller tunnel install # one time, downloads cloudflared
controller upcontroller up prints something like:
https://random-words-1234.trycloudflare.com
Open that on the phone. Cloudflare quick tunnels need no account.
If cloudflared is unavailable, controller tunnel falls back to ngrok if it is installed and authenticated:
ngrok config add-authtoken <your-token>
controller tunnelSecurity: the server has no authentication. Anyone with the URL can control the pad. Use it on a trusted network, don't expose port 5000 to the internet, and stop the tunnel when you're done (controller tunnel stop). The tunnel URL is random but public.
Run the one-time setup:
controller setupThen check:
controller doctor
ls -l /dev/uinput # should be crw-rw---- root input
id -nG # should include "input"If you were just added to the input group, log out and back in. You can also test immediately with sudo chmod 660 /dev/uinput (temporary until reboot).
controller logsCommon causes: missing websockets (run ./install.sh), no uinput permission (controller setup), or port 5000 already in use (controller stop, or EZC_PORT=5001 controller).
controller status
controller stop
# or
EZC_PORT=5001 controller- Make sure the server is running:
controller status. - Phone and PC must be on the same network for the LAN URL.
- If using a tunnel, wait a few seconds for the URL to become active.
- Check the log:
controller logs -f.
Open ⚙ SETTINGS and read the GYRO line — it tells you what is wrong:
| GYRO line | Fix |
|---|---|
OFF IN COZY PRESET |
Switch the preset to 🏎️ RACING or 🏍️ MOTOGP. Cozy uses the touch left stick and intentionally ignores the gyro. |
NEEDS HTTPS / BLOCKED |
Open the https:// URL from controller up. Mobile browsers block motion sensors on plain http:// LAN addresses. |
PERMISSION DENIED |
Allow Motion & Orientation Access for the browser, then reload and tap SYS.IGNITION again. On iOS: Settings → Safari → Motion & Orientation Access. |
NO EVENTS |
The browser is not delivering sensor data. Try Chrome/Safari over HTTPS, and make sure the phone is not in a low-power/battery-saver mode. |
LIVE · GRAVITY … / LIVE · THR … BRK … |
Sensors are working. Steering/lean auto-centers on the first sample; tap CALIBRATE CENTER while holding your normal driving position if it drifts. |
LIVE (FALLBACK) · β … γ … / FALLBACK · ALLOW MOTION FOR TILT THROTTLE |
Motion permission was denied, so the less stable orientation fallback is in use. Reload over HTTPS and allow both Motion & Orientation prompts. MotoGP cannot use gyro throttle/brake without motion access. |
Steering and MotoGP throttle/brake are derived from the gravity vector, not raw Euler angles. That avoids the ±90° gimbal-lock flip you get when holding the phone upright. Hold the phone upright in landscape and rotate it like a steering wheel. If left and right are reversed, tick INVERT. If MotoGP accelerates when you pull back, tick INVERT TILT.
Quick tunnel reminder:
controller up
# open the printed https://...trycloudflare.com URL on the phoneOpen ⚙ SETTINGS:
- Tap INVERT if left/right are reversed.
- Hold the phone in your normal driving position and tap CALIBRATE CENTER.
- Lower SENSITIVITY for less twitchy steering; raise it for more.
- Start the server before launching the game.
- Check
controller status— the pad line should sayLinux uinput Xbox 360 pad. - Verify the device exists:
ls /dev/input/by-id/ | grep -i xboxorcat /proc/bus/input/devices. - In Steam, enable Xbox 360 Controller support if needed.
- Some games only enumerate controllers at startup — restart the game.
That is the secure-context restriction. Use controller up and open the HTTPS URL. On iOS, also allow Motion & Orientation Access when prompted (Settings → Safari → Motion & Orientation Access).
- Prefer 5 GHz Wi-Fi and keep the phone and PC on the same access point.
- Close other browser tabs/apps on the phone.
- Don't use a free tunnel if you can avoid it; LAN is faster. If you need gyro, the tunnel is the tradeoff.
- Check
controller status— message count should be increasing.
server.py HTTP + WebSocket server, hot input path
ezcontroller/
pad.py Linux uinput + Windows vgamepad + mock backends
protocol.py JSON → normalised InputState
__init__.py version
controller Linux CLI (background daemon, tunnel, doctor)
install.sh non-root installer, optional .venv
99-ezcontroller.rules udev rule for /dev/uinput
index.html phone UI (gyro, touch, presets, tune panel)
requirements.txt Linux/server dependencies
requirements-windows.txt Windows dependencies (adds vgamepad)
racing.bat original Windows launcher (still works)
test.html old connection test page (unused)
Phone browser (gyro + touch)
│ JSON frames, next animation frame
▼
WebSocket :5000 (no compression, TCP_NODELAY)
│
server.py → InputState → one batched write per frame
│
▼
/dev/uinput → "Microsoft X-Box 360 pad"
│
▼
Linux input stack → SDL / Steam / Proton / native game
On Windows the last two steps are replaced by vgamepad → ViGEmBus → virtual Xbox 360 controller.
Original concept, UI, and Windows backend by devsurx. Linux uinput backend, background CLI, latency work, and HTTPS helper added in 2.0.

