Game Controller on macOS: Device Discovery, Profiles, and Input Ownership
Build controller input around connection notifications, capability profiles, callback ownership, player assignment, focus, and disconnect recovery.
The Game Controller framework represents connected controllers through GCController objects and exposes their capabilities through input profiles. A controller connection is not equivalent to a particular layout: one device may expose an extended gamepad profile, another a more limited profile, and some supported devices expose keyboard or mouse input. The app should discover the active devices, inspect the available profile, and translate inputs into its own semantic actions.
Treat the controller as an input source with a lifecycle, not as a singleton that exists for the entire app. Devices can connect and disconnect while gameplay is active; player assignment and focus can change; and input callbacks may continue after a scene or window has ended. A controller service should own observer tokens, per-device mappings, callback teardown, and the current gameplay generation.
Discover connected devices and react to changes
Enumerate GCController.controllers() during setup so devices already connected before the app registered observers are not missed. Then observe connect and disconnect notifications. Use notification objects to identify the controller involved and update the app’s device registry. Register once at the lifecycle scope that owns input; repeated view appearances should not install duplicate observers.
import Foundation
import GameController
final class ControllerRegistry {
private var tokens: [NSObjectProtocol] = []
private(set) var connected: [ObjectIdentifier: GCController] = [:]
func start() {
let center = NotificationCenter.default
tokens.append(center.addObserver(forName: .GCControllerDidConnect,
object: nil, queue: .main) { [weak self] note in
guard let controller = note.object as? GCController else { return }
self?.connected[ObjectIdentifier(controller)] = controller
})
tokens.append(center.addObserver(forName: .GCControllerDidDisconnect,
object: nil, queue: .main) { [weak self] note in
guard let controller = note.object as? GCController else { return }
self?.connected.removeValue(forKey: ObjectIdentifier(controller))
})
for controller in GCController.controllers() {
connected[ObjectIdentifier(controller)] = controller
}
}
func stop() {
for token in tokens { NotificationCenter.default.removeObserver(token) }
tokens.removeAll()
connected.removeAll()
}
}
This registry is deliberately separate from a particular view controller. A full implementation should also clear a player’s assignment and release profile handlers when a device disconnects. Reconcile enumeration and notifications carefully so startup does not lose a connect event that arrives around observer installation.
Inspect profiles and build semantic controls
Check profile availability before reading inputs. extendedGamepad is optional; a missing extended profile is not a broken controller. Select the supported profile and map its buttons, triggers, axes, and directional pads into actions such as confirm, navigate, pause, or move. Keep that mapping outside gameplay code so layouts can be customized and a device with a different profile can still participate.
Input values are physical controls, not universal meanings. A button labeled A can be placed differently or reported with platform-specific conventions. A thumbstick may need a configurable dead zone and response curve; triggers may report both an analog value and a pressed state. Normalize values deliberately, preserve analog information where gameplay requires it, and test the actual profiles the application claims to support.
The framework lets an app poll values during a game loop or register value-change handlers. Polling aligns reads with simulation ticks and avoids a callback flood, but it must not block rendering. Change handlers can reduce polling for event-driven interfaces, but keep the handler short and pass a compact input snapshot to the simulation or main actor. Do not mutate complex gameplay state concurrently from arbitrary callback contexts.
Normalize each device at the input boundary. Axis values should be clamped to the documented range before applying a configurable dead zone, and digital actions should use a clear press/release threshold with hysteresis if noisy analog transitions would otherwise chatter. Keep raw values available for calibration and diagnostics, but publish semantic actions such as “move left” or “confirm” to gameplay. This gives each game mode one input contract even when hardware profiles differ.
Avoid polling the same profile from unrelated systems. A single sampling owner can read values once per simulation tick and fan out an immutable snapshot to gameplay, UI prompts, and telemetry. If callbacks are used instead, attach a monotonically increasing sequence or timestamp to snapshots before forwarding them across queues. This makes it possible to detect stale input after a window focus change and prevents a slower UI task from replacing a newer control state.
Define pause behavior for the controller’s pause button and for application suspension separately. A pause-button callback is one signal from the device; it does not decide whether the application pauses, opens a menu, or ignores the request. If the system interrupts the app or the user switches away, clear transient input state and reacquire focus before resuming. Test a controller that remains physically connected throughout the interruption as well as one that disconnects.
Do not treat a button callback as a complete gameplay event by itself. Some controls have analog magnitude, touch state, and press state, and a profile may report an element change even when the higher-level action has not crossed the game’s threshold. Convert device input into a normalized snapshot first, then let the active input mapping decide whether the action began, continued, or ended. This makes accessibility remapping and user-configured layouts easier to test.
Input ownership also needs a policy for menus and gameplay. A menu may consume directional input while gameplay is paused, whereas the same d-pad should move a character during play. Route semantic actions through the current interaction mode instead of having each screen install a separate controller callback. On mode change, reset repeat timers and held-state bookkeeping so a key repeat from the menu cannot become a movement command in the next mode.
Player ownership, focus, and pause
Multiple controllers can be connected. Use player assignment and the framework’s current-controller behavior according to the experience, but do not assume the last connected device is always the user-selected player. If the product supports multiplayer, persist explicit controller-to-player assignment while the session is active and define what happens when a controller disconnects. If one device becomes unavailable, pause or reassign only according to game policy.
AppKit keyboard focus, SwiftUI focus, and Game Controller input are related but distinct. A controller event should be delivered to the active interaction owner, not to a hidden or closed window. When gameplay loses focus, decide whether to pause, ignore gameplay input, or keep a dedicated controller task running. Reset held-button state after disconnect or focus loss so a stale “pressed” value cannot become stuck.
Do not keep UI objects strongly captured by a profile’s value-change closure. Store the handler owner at a stable scope, use a session generation to reject late input, and set handlers to nil when the game session ends. The framework device can outlive one screen; the app’s use of its input should not.
Discovery policy and user feedback
Wireless discovery is an active search and should not run indefinitely without product need. Start it only when the UI offers a discover or connect action, and stop it when a device is selected or the flow closes. Re-enumerate already connected devices after returning to a pairing screen. Avoid telling users that a controller is “paired” merely because it appeared in an input enumeration; Bluetooth pairing and the app’s player assignment are separate states.
Expose controller presence and supported controls in an accessible way. Provide keyboard or pointer alternatives for essential navigation where the product permits them. A small connection indicator is useful, but do not rely solely on color or a transient toast to explain that player input has moved to another device.
Acceptance and diagnostics
Test no controller, a controller connected before launch, hot-plug, disconnect while a button is held, duplicate notifications, unsupported profile, profile changes, analog dead zones, simultaneous devices, player reassignment, focus change, and teardown while a handler is pending. Check that one physical action produces one app action and that disconnect removes stale held values.
Log device class, profile type, player assignment, connection transition, input source, and session generation without recording detailed user input traces by default. Measure connection-to-ready time, dropped simulation ticks, duplicate action count, and stale callback rejection. A reliable controller layer maps optional profiles into app-defined actions and has an explicit policy for device loss and focus.
Related:
- Fixing Bluetooth Connectivity Issues on macOS
- AppKit Custom Accessibility: Roles, Actions, and Virtual Elements
Sources: