Gamepads
Read game controllers from one API, on every platform Shiny runs on. Built on Shiny.Core, so it
runs in any Shiny host — MAUI, Blazor, a console app, a tvOS app or a headless Raspberry Pi.
| GitHub | |
| Downloads |
Read this first
Section titled “Read this first”Three things decide most of what you build with this, and none of them are obvious from the API.
Buttons are named by position, not by the label printed on the pad. GamepadButton.A is the
bottom face button everywhere: A on an Xbox pad, Cross on a PlayStation pad, and B on a Nintendo
pad, whose face buttons are physically mirrored. GamepadButton.Y is the top one. This is the only
mapping under which “A jumps” means the same thing on every controller, and it is what every platform
SDK underneath already normalises to. IGamepad.Kind exists so you can draw the right glyph — it
changes nothing about reading input.
Stick Y is positive upwards. Android, Linux and the browser all report it positive downwards natively; each backend flips it, so a stick pushed away from the player always reads positive. Don’t flip it again.
Everything past sticks and buttons is per-controller, not per-platform. The same DualSense
reports motion and a light bar over USB on Linux and neither of them in a browser, because what is
missing is the API rather than the hardware. So every controller publishes a
GamepadCapabilities flags property, and anything unavailable throws
GamepadNotSupportedException naming the flag and the platform reason.
# Android, iOS, tvOS, Mac Catalyst, macOS, Windowsdotnet add package Shiny.Gamepad
# Linux desktop or Raspberry Pi — instead of the abovedotnet add package Shiny.Gamepad.Linux
# Blazor WebAssembly — instead of the abovedotnet add package Shiny.Gamepad.Blazorbuilder.Services.AddGamepads();The Linux and Blazor packages register a real implementation under the same AddGamepads name
on the same target framework, so reference one of them instead of the base package rather than
alongside it. For a server, console or test host with no controller API at all, register
AddNotSupportedGamepads() — it reports no controllers, forever, and never throws.
No permission, entitlement or manifest entry is needed on any platform. Controllers are not a privacy surface. Two things look like permissions and are not — the browser’s anti-fingerprinting rule and Linux device node access — both covered in Platform Setup.
Finding controllers
Section titled “Finding controllers”public class GameLoop(IGamepadManager manager){ IGamepad? pad;
public async Task Start(CancellationToken ct) { manager.Connected += (_, e) => this.pad ??= e.Gamepad; manager.Disconnected += (_, e) => { if (this.pad?.Id == e.Gamepad.Id) this.pad = null; };
// this call starts the watch — nothing happens until it has run once var pads = await manager.GetGamepads(ct); this.pad = pads.FirstOrDefault(); }}GetGamepads is the only asynchronous step. Everything a frame needs after it — GetState() above
all — is synchronous.
Registering the service touches no hardware: a console host that resolves IGamepadManager and
never asks for a controller opens no device node, hooks no activity and subscribes to no
notification centre.
// the usual opening move for a game — returns at once if one is already onvar pad = await manager.WaitForGamepad(ct);
// "press any button to start"await pad.WaitForButton(ct: ct);Reading a controller
Section titled “Reading a controller”There are two ways, and you generally want both. They are derived from the same state on every platform, so they cannot disagree with each other.
A snapshot, for a render loop
Section titled “A snapshot, for a render loop”var state = pad.GetState();
var movement = state.GetMovement(); // left stick, 0.15 deadzone appliedvar look = state.GetLook(); // right stick
if (state.IsPressed(GamepadButton.A)) Jump();
// several buttons means ALL of them — a chordif (state.IsPressed(GamepadButton.LeftShoulder | GamepadButton.RightShoulder)) Reload();
// ... and IsAnyPressed means eitherif (state.IsAnyPressed(GamepadButton.Start | GamepadButton.Select)) OpenMenu();
Accelerate(state.RightTrigger); // 0 to 1var direction = state.DPad; // the D-pad as a stick, so it drives the same codeGamepadState is an immutable value, not a view, so two of them can be compared to work out what
the player did. It never blocks, and it is safe to call after a disconnect — it keeps returning the
last state seen, so a render loop needs no guard.
Events, for menus and UI
Section titled “Events, for menus and UI”pad.ButtonChanged += (_, e) =>{ if (e.Button == GamepadButton.Start && e.IsPressed) OpenMenu();
// e.State is the whole pad at that instant, for reading modifiers if (e.Button == GamepadButton.A && e.IsPressed && e.State.IsPressed(GamepadButton.LeftShoulder)) AlternateAction();};
pad.AxisChanged += (_, e) =>{ if (e.Axis == GamepadAxis.LeftStickY && e.Value > 0.5f && e.PreviousValue <= 0.5f) MoveSelectionUp();};One event per button, never a combined mask. Two buttons pressed in the same frame raise two
events — a handler switching on e.Button would otherwise silently miss the second.
Events are raised on whatever thread the platform delivered input on: the main thread on Android and in the browser, a background queue on Apple, the reader thread on Linux, the poll timer on Windows. Marshal before touching UI.
pad.ButtonChanged += (_, e) => MainThread.BeginInvokeOnMainThread(() => this.Handle(e));Deadzones
Section titled “Deadzones”GetState() never applies one, because the right value depends on the game. A resting thumbstick
does not read zero.
var movement = state.LeftStick.WithDeadzone(0.15f);WithDeadzone is radial — it looks at how far the stick is pushed overall — and rescales
what is left so the value still reaches 1 at full deflection.
Both halves matter:
- Deadzoning each axis independently is the classic bug that makes a stick feel like it has eight directions. Pushed hard up and slightly right, the small X is discarded while the large Y survives, and the stick snaps to the compass points.
- Cutting without rescaling makes the value jump from 0 straight to the deadzone the moment the stick leaves the dead area, so slow movement is impossible.
Magnitude can slightly exceed 1 in the diagonals — most hardware reports a square range rather
than a circular one, so a stick held to a corner reads about 1.41. Clamp if that matters.
Axis events and the change threshold
Section titled “Axis events and the change threshold”manager.AxisChangeThreshold = 0.05f; // default 0.02This is not a deadzone. It is how far an axis must move to be worth an event, measured against the value that axis last reported — not against the previous state. That distinction matters: a stick swept slowly, a few thousandths per frame, would never cross a per-frame threshold and would appear frozen to an event consumer.
An axis landing exactly on centre, +1 or −1 always reports, whatever the threshold, because swallowing the return to zero leaves the player walking into a wall.
It has no effect on GetState(), which is always raw. Raise it for menu navigation, lower it for a
racing game’s steering.
Rumble
Section titled “Rumble”if (pad.Supports(GamepadCapabilities.Vibration)){ // sets a LEVEL that holds until changed await pad.SetVibration(new GamepadVibration(LowFrequency: 0.7f, HighFrequency: 0.2f));
// ... and do not forget this await pad.SetVibration(GamepadVibration.Off);}
// one-shot bump: sets the level, waits, clears it — even if cancelledawait pad.Pulse(0.8f, TimeSpan.FromMilliseconds(120));The two handle motors are not the same part:
LowFrequencyis the heavy off-centre weight in the left handle — a low rumble you feel in the palm, for impacts and engines.HighFrequencyis the smaller, faster weight in the right handle — a buzz, for clicks and surface texture.
Driving both at once reads as “everything is shaking”, which is rarely what a moment calls for.
LeftTrigger and RightTrigger reach motors inside the triggers and need
GamepadCapabilities.TriggerVibration. Where that is missing they are ignored rather than folded
into the handles — a trigger effect that silently becomes a whole-pad rumble is worse than one that
does nothing.
Always stop the motors. A controller left rumbling keeps going after the app is backgrounded on some platforms, and until its battery dies on others.
Battery, motion and light
Section titled “Battery, motion and light”if (pad.Supports(GamepadCapabilities.Battery)){ var battery = await pad.GetBattery(); // battery.Level is 0–1 or null; battery.State is Charging / Discharging / Full / Wired / Unknown}
if (pad.Supports(GamepadCapabilities.Motion)){ await pad.SetMotionEnabled(true); // off by default everywhere — the sensors cost battery
pad.MotionChanged += (_, e) => Aim(e.Motion.AngularVelocityX, e.Motion.AngularVelocityY); var latest = pad.GetMotion(); // the polling counterpart; null until a sample arrives
await pad.SetMotionEnabled(false);}
if (pad.Supports(GamepadCapabilities.Light)) await pad.SetLight(GamepadLight.FromRgb(0, 128, 255));Battery is reported coarsely — four steps is common, and some controllers only ever say “fine” or “nearly empty”. Treat the level as a gauge to draw, not a number to do arithmetic on.
Acceleration includes gravity: a controller resting on a table reads about 1g on whichever axis points up, not zero. Subtract a low-passed average to get the part the player caused. Rotation is radians per second, acceleration is in g, and the axes follow the controller’s own body.
Identifying controllers
Section titled “Identifying controllers”pad.Name // the platform's name for it — often generic ("Wireless Controller")pad.Kind // Xbox / PlayStation / Nintendo / Steam / Remote / Standard — for glyphs onlypad.PlayerIndex // the slot the platform assigned, from 1, or nullpad.Id // unique among connected controllerspad.SupportedButtons // tells "not pressed" apart from "not present"Id is always unique among the controllers connected right now. Whether it survives a reconnect
depends on the platform, and GamepadCapabilities.PersistentId says which: Windows
(NonRoamableId) and Linux (the controller’s MAC or serial) can name a specific physical pad across
reboots; the Apple, Android and browser APIs hand out a fresh identity every time.
Never key saved per-player settings on Id without checking that flag first.
A disconnected IGamepad stays valid but inert — IsConnected turns false, GetState() keeps
working, and everything else throws GamepadDisconnectedException. A controller that comes back is a
new instance, not the old one revived.
Multiple players
Section titled “Multiple players”// PlayerIndex is the platform's opinion, and is null on Windows and in the browservar p1 = await manager.GetGamepadForPlayer(1);
// assign your own slots where the platform assigns nonevar assignments = new Dictionary<string, int>();manager.Connected += (_, e) => assignments[e.Gamepad.Id] = assignments.Count + 1;manager.Disconnected += (_, e) => assignments.Remove(e.Gamepad.Id);Exceptions
Section titled “Exceptions”| Exception | Meaning |
|---|---|
GamepadNotSupportedException |
The controller or the platform lacks the capability. Carries the Capability flag and names the platform reason |
GamepadDisconnectedException |
The controller went away. GetState() is exempt and keeps working |
GamepadException |
Anything else — the platform refused, or a device node could not be opened |
Next: Platform Setup for what each backend can and cannot do, and the two things that look like permissions but are not.


