
Mobile Game Controller Crash Debugging Guide
Mobile game controller crash debugging sits at the intersection of three failure domains that rarely show up together in ordinary app work: low-level Bluetooth HID transport, an OS-level input abstraction, and a game engine that re-maps raw button events into gameplay actions. When a gamepad disconnects mid-session, when a GCController object is deallocated while a handler still holds it, or when Android hands you a stale InputDevice id after a reconnect, the resulting crash is almost never reproducible on a desk — it surfaces only on a real couch, with a real controller, in a real game. This guide walks through the most common controller crash modes on iOS and Android, then shows how to catch them before your players do.
Why controller crashes slip past normal testing
Ordinary crash tooling is built around touch input, and that is exactly why external input peripherals keep shipping regressions. A touch event is generated by the device itself, arrives on the main thread, and carries no lifecycle of its own. A game controller is a separate Bluetooth HID device with its own connect, reconnect, and disconnect lifecycle, and its events can be dispatched on a different thread than the one your gameplay code expects. Apple's Game Controller framework and Android's game controller support both model the controller as a first-class object that can vanish at any moment, and both leave it to you to handle that vanishing gracefully.
The pattern is consistent across platforms: the OS tells you a controller arrived, you stash a reference to it somewhere, the controller drops off the bus, and that reference goes stale. The crash that follows is usually a use-after-free, a dangling delegate, or an IllegalArgumentException from a recycled input identifier. None of these show up in the simulator, because the simulator has no real HID device to drop.
iOS: the GCController lifecycle
On iOS, controllers arrive and depart through two notifications: GCControllerDidConnectNotification and GCControllerDidDisconnectNotification. The classic bug is registering an observer, capturing controller strongly inside the handler, and then assuming that object outlives the match:
NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect,
object: nil,
queue: .main
) { note in
guard let controller = note.object as? GCController else { return }
controller.playerIndex = .index1
controller.extendedGamepad?.valueChangedHandler = { gamepad, element in
self.handleInput(gamepad, element) // self retained forever
}
}The valueChangedHandler closure captures self strongly while the controller holds the handler, and the controller itself is retained by the framework for the life of the connection. The result is a retain cycle that keeps your gameplay scene alive long after the match ends — and, worse, keeps writing input into a deallocated scene if you break the cycle the wrong way. Apple's GCExtendedGamepad documentation explicitly warns that handler closures are called on an arbitrary queue, so mutating UI from inside one is a second, unrelated crash waiting to happen.
The robust pattern is to use [weak self] and to re-fetch the controller from the notification's object rather than caching it, then to nil out the handler in GCControllerDidDisconnect:
controller.extendedGamepad?.valueChangedHandler = { [weak self] gamepad, element in
DispatchQueue.main.async { self?.apply(gamepad, element) }
}This avoids both the retain cycle and the cross-thread UI mutation in one move.
iOS: profile snapshot races and dangling objects
GCExtendedGamepad and GCMicroGamepad are "profiles" that describe the physical buttons a given controller exposes. A controller can switch profiles as the user presses the Home or Menu button, and the framework can hand you a fresh profile object mid-frame. Code that caches controller.extendedGamepad at connect time and reads it later can dereference a profile that has already been replaced — the exact analog of the deallocation-while-retained bug described in the GCController class documentation.
The fix is to treat the profile as ephemeral. Read it fresh on every event, guard against nil when the controller is in the menu state, and never store the profile in a property that outlives a single input frame. If you must cache, compare controller object identity before touching any profile, and reset your cache in the disconnect handler.
A related trap is a GCController retained by a stale CADisplayLink. If you register a display link to poll the controller's extendedGamepad every frame and forget to invalidate the link when the controller disconnects, the link keeps firing against a controller object the framework has already torn down. Always invalidate display links and timers in GCControllerDidDisconnectNotification, and check controller.isAttachedToDevice before each poll.
Android: InputDevice and SOURCE_GAMEPAD
Android routes gamepad input through the same InputDevice machinery as keyboards and mice, which means a gamepad is not always a gamepad unless you check its sources. InputDevice.getSources() returns a bitmask, and you must test for SOURCE_GAMEPAD or SOURCE_JOYSTICK before interpreting motion events as analog stick data:
fun isGamepad(device: InputDevice): Boolean {
val sources = device.sources
return sources and (InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK) != 0
}Treating every MotionEvent as a gamepad is a fast path to a crash: a Bluetooth keyboard and a gamepad both emit key events, but only the gamepad carries AXIS_X and AXIS_Y motion data. The SOURCE_GAMEPAD reference makes clear these source bits are additive, so a controller can report multiple sources at once, and your bitmask test must be an OR, not an equality check.
Android: HID disconnects and stale device ids
The most common production crash on Android is a stale InputDevice id. The deviceId value in a KeyEvent or MotionEvent is only meaningful for the duration of the input stream that produced it. When a Bluetooth HID gamepad drops and reconnects, the OS assigns a brand new id, and any code that cached the old id — for haptics, for per-player mapping, for rumble — is now addressing a device that no longer exists. Calling InputDevice.getDevice(oldId) returns null, and dereferencing it crashes.
The fix is to always resolve the device from the event that is currently in flight, never from a cached field:
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
val device = event.device ?: return false
if (!isGamepad(device)) return false
handleAxes(device, event)
return true
}event.device is populated for you and is always consistent with the event's own id. If you need a long-lived handle for rumble, key it by the controller's Bluetooth MAC address rather than the transient deviceId, and re-resolve the id on every reconnect through the Bluetooth HID profile stack.
Game engines: Unity Input System
Unity's new Input System exposes a Gamepad device that, like Apple's GCController, can be removed while your scripts still hold a reference. A Gamepad.current read that happens on a frame where the device was just disconnected returns null, and a null-dereference in an Update() loop is one of the most common crash reports in shipped mobile games.
void Update()
{
var pad = Gamepad.current;
if (pad == null) return;
Vector2 move = pad.leftStick.ReadValue();
// ...use move
}The guard is cheap and the absence of it is expensive. The legacy Input Manager has its own flavor of this bug: joystick axis bindings silently stop updating when a device drops, but the bindings themselves are never invalidated, so gameplay code keeps reading a frozen vector and behaves as if the stick were held in place. Pairing an Input System device-removed callback with an in-game "controller disconnected" overlay prevents both the crash and the confused-player bug report.
Cross-cutting crash modes to watch for
A few failure modes span both platforms and every engine. Rumble and haptics crash when a disconnect lands between the "device is present" check and the "send haptic" call — wrap haptics in a try/catch and a liveness re-check. Multi-controller index handling breaks when the second controller connects before the first finishes registering, producing an index collision that crashes the player-assignment table. And input events dispatched on the wrong thread are the silent killer: on iOS the handler queue is arbitrary, and on Android you should marshal gameplay mutations onto the main thread rather than mutating state from the input callback. Finally, watch for HID descriptor parsing failures: some third-party gamepads advertise malformed report descriptors, and both platforms will either fail to enumerate the device or deliver events whose axes are mislabeled. Defensive input code should tolerate a device that reports fewer axes than your mapping table expects.
Instrument the whole lifecycle
The single highest-value investment for mobile game controller crash debugging is to log the controller lifecycle as a series of breadcrumbs — connect, profile change, disconnect, and every haptic call — so that when a crash lands, you can see exactly which transition preceded it. BugsPulse captures these breadcrumbs alongside the crash report, so a "random gamepad crash" on a Wednesday night becomes "disconnect received, then a haptic call on device id 7, then a null dereference." That context is the difference between a one-line fix and a week of speculation. Learn more about how BugsPulse fits into your crash-debugging workflow.
If you are shipping a game, controller input is not an edge case — it is a core input surface for a large and vocal slice of your audience, and the crash modes above reproduce at scale even when they never reproduce locally. For engine-level game crashes as opposed to peripheral input crashes, see our Unity and Unreal crash guide; this guide is its complement, focused entirely on the external input dimension.
Instrument your controller lifecycle, guard every dereference, and resolve devices from live events instead of cached ids. Then start your free trial to put BugsPulse on every device your players actually use: https://app.bugspulse.com/register.