Engineering

Last-input-wins device detection across Godot and Unity

  • Unity
  • Godot
  • Input System
  • InputAction
  • UX
  • Game Development

Two of my side projects live in different engines right now. One is a first-person simulation I’m building in Godot, the other an action game in Unity. They share almost no code. They do share a small UX problem: a player on keyboard shouldn’t see “Press A to interact” prompts, and a player on a gamepad shouldn’t see “Press E”. And the player never volunteers which they’re on.

I solved it in Godot first, went in a few circles, then ported the cleaner version back into the Unity project. The shape of the answer ended up almost identical, which surprised me enough to write this down.

The thing I was trying to avoid

I didn’t want every UI screen calling IsGamepadConnected() and deciding on its own. That breaks the moment a player has a controller plugged in but is still typing on the keyboard. Connection state answers a different question — which devices are available, not which one the player is using right now.

So the rule I wanted everywhere was:

The active device is whichever one produced the most recent input event. UI prompts, focus rules, and cursor behaviour follow that.

One source of truth, plus a change event for anyone who cares.

Godot side: tripping over my own stick deadzone

I started in Godot. The first version was naive: classify every InputEvent the autoload receives, update a LastUsedDevice field, raise a signal on change.

public override void _Input(InputEvent @event)
{
    UpdateLastUsedDevice(@event);
}

That fell apart immediately. With a gamepad plugged in, InputEventJoypadMotion fires constantly with tiny sub-deadzone values, even with the stick at rest. So my “last used” flipped to Gamepad on the first frame and stayed there forever. The keyboard couldn’t win even when I was actively typing on it.

The fix is to ignore joypad motion events below a threshold. I pulled the classifier out into its own static so I could test it directly:

public static InputDeviceKind? Classify(
    InputEventKind eventKind,
    float joypadAxisValue = 0f,
    float joypadMotionThreshold = DefaultJoypadMotionThreshold)
{
    return eventKind switch
    {
        InputEventKind.Key => InputDeviceKind.KeyboardMouse,
        InputEventKind.MouseButton => InputDeviceKind.KeyboardMouse,
        InputEventKind.MouseMotion => InputDeviceKind.KeyboardMouse,
        InputEventKind.JoypadButton => InputDeviceKind.Gamepad,
        InputEventKind.JoypadMotion =>
            MathF.Abs(joypadAxisValue) > joypadMotionThreshold
                ? InputDeviceKind.Gamepad
                : (InputDeviceKind?)null,
        _ => null,
    };
}

A return value of null means “this event doesn’t tell me anything new”, and the caller leaves LastUsedDevice alone. That’s the actual anti-flicker mechanism. No time-based debounce, just a check that the event represents a deliberate gamepad input.

Two things I’d flag from this in hindsight.

The threshold I landed on is deliberately different from Godot’s normal action deadzone. I’m using 0.5 here, which is higher than my action deadzone. Action deadzones decide whether to fire an action; this threshold decides whether a stick wobble counts as “the player picked up the controller”. I wanted the second one to be more conservative in my project, because in my testing the cost of getting it wrong was a “Press A” prompt showing up while I was actively typing.

InputEventMouseMotion gets classified as keyboard/mouse. I had a half-formed plan to suppress it while the FPS cursor is captured, since mouse motion is being consumed by the camera at that point and there’s no UI prompt visible anyway. In the end I left it as-is. The classifier still treats captured-mouse motion as keyboard/mouse, but no UI cares during gameplay, so the special case wouldn’t pay for itself.

A signal, not a state to query

The thing that took me a while to commit to: the active device should be pushed, not pulled.

My first version had every UI panel reading InputSystem.Instance.LastUsedDevice in _Process. Polling works for a tab bar where you’re already updating per frame, but it’s pure waste for an interactable prompt that hasn’t changed in five seconds. The bigger problem is the failure mode: when a panel forgets to re-poll, an old “Press A” sticks around silently because nobody asked.

The interactor in my game now subscribes once and reacts:

public override void _Ready()
{
    _subscribedInput = InputSystem.Instance;
    var initialDevice = _subscribedInput?.LastUsedDevice ?? InputDeviceKind.KeyboardMouse;
    _glyph = InputGlyphResolver.Resolve(InputActionNames.Interact, initialDevice);
    if (_subscribedInput != null)
        _subscribedInput.DeviceChanged += OnDeviceChanged;
}

private void OnDeviceChanged(InputDeviceKind device)
{
    _glyph = InputGlyphResolver.Resolve(InputActionNames.Interact, device);
    EmitContext();
}

The glyph resolver is a small helper that walks InputMap.ActionGetEvents and picks the binding that matches the current device kind, falling back to the first available one. The interactor builds an InteractionContext carrying that glyph and emits it on a channel; the UI listens to the channel. No part of the UI knows what InputDeviceKind is.

The ui_focus thing I keep needing to disable

Godot has built-in ui_focus_next/ui_focus_prev actions, and when you have Control nodes marked FocusMode.All, the engine will happily move focus around with Tab (and with the gamepad’s d-pad, if you’ve bound it). It’s a useful default, until it fights with the gameplay layer above it.

Two cases that bit me.

The tab bar at the top of a menu has its own left/right action bindings, and I want d-pad left/right to switch tabs. But each tab button is also a Control with focus on by default, so the engine’s focus traversal moves focus among the buttons instead. The fix is unglamorous: set FocusMode = FocusModeEnum.None on the tab buttons themselves. The tab bar handles the input, the buttons render state, and the engine stops trying to be helpful.

Slot grids in inventory and recipe lists have the opposite problem. I do want focus traversal there, but only on keyboard or gamepad. When the player clicks with a mouse, focus chasing the cursor flickers every slot it passes over. For now I’ve leaned on FocusEntered / FocusExited for hover state and accepted the flicker. The cleaner alternative is to toggle FocusMode per slot when the device changes, but that has its own edge cases and I haven’t been bitten hard enough yet to do it.

Porting it to Unity

The action-game project in Unity gave me two reasonable options: use PlayerInput with control schemes, or drive everything through InputAction references on a ScriptableObject. I picked the second one because the rest of that project already runs on event channels (I’m using my Reactive SO package), and routing input through the same plumbing keeps gameplay code blissfully unaware of Unity’s input API.

The reader’s job is to subscribe to performed and canceled on each action, and re-publish what it hears as typed events on EventChannelSOs. Move and Look go through Vector2EventChannelSO. Jump and Cancel use VoidEventChannelSO. Sprint and Attack publish hold state on BoolEventChannelSO. Gameplay code reads channels, never InputActions.

The device detection rides on the same callbacks. Every action handler also runs the new event through the classifier:

private void UpdateDeviceFromContext(InputAction.CallbackContext ctx)
{
    var device = ctx.control?.device;
    if (device == null) return;

    var (kind, axis) = ToClassifierInput(device, ctx);
    var classified = InputDeviceClassifier.Classify(kind, axis);
    if (!classified.HasValue) return;
    if (classified.Value == LastUsedDevice) return;

    LastUsedDevice = classified.Value;
    OnDeviceChanged?.Invoke(LastUsedDevice);
}

ToClassifierInput flattens Unity’s InputDevice hierarchy into the same InputEventKind enum the Godot side uses. Keyboard and Mouse map to keyboard/mouse. Gamepad and Joystick map to JoypadButton for buttons and JoypadMotion when the action’s value type is Vector2, in which case I pass the vector magnitude into the same threshold check as on the Godot side.

The InputDeviceClassifier itself ended up being almost the same static class as on the Godot side. That wasn’t deliberate. Once I pulled the “classify a single event” logic away from engine-specific event types, the body of the method came out roughly the same in both projects.

InputReaderSO exposes LastUsedDevice plus a plain C# OnDeviceChanged event. I thought about routing the device change through yet another EventChannelSO, but a C# event is fine here. There’s exactly one reader asset in the project, and subscribing to it costs the same as any other ScriptableObject reference.

InputMode is a different signal, and I keep wanting to merge them

The Unity project also has a second concept floating around called InputMode, with two values: Gameplay and Ui. That one tracks what the player is doing (playing versus paused or in a menu) and decides which InputActionMap is enabled, what the cursor does, and whether time is frozen. It rides on its own channel, an InputModeEventChannelSO that replays the most recent value to late subscribers so cursor, time and UI listeners stay consistent after a transition.

My first instinct, when I added InputMode, was to fold it into the device signal. One big “input state” enum covering both axes. I’m glad I didn’t, at least for this project. In my code they change for different reasons, on different timescales, and the listeners I have almost never want both. The prompt glyph cares about device kind and ignores mode. The pause menu is the reverse. The cursor wants both, but it’s been happy reading two channels.

I’d talked myself into the merged enum on paper, and then every concrete listener I sketched out only used one half. That was enough to keep them separate for now.

What stayed the same across engines

The static classifier moved between projects almost verbatim. The only real diff is one C# language version difference. That alone made me trust the pattern more.

The consumer side has the same shape too. Subscribe once on enable, unsubscribe on disable, react on change, and fall back to the current value at startup so the first frame isn’t blank.

Both engines also ship a default focus path that wants to “help”. Godot’s ui_focus_* actions on one side; Unity’s UI Input Module behaviour with UI Toolkit on the other. I hit the Unity version for an unrelated reason, where disabling every UIDocument silently disables the InputActionAsset behind it. The thing I keep tripping over in my own code is not being sure where the engine’s defaults end and my code starts. Whenever I’ve reached for “just disable all the defaults” as a shortcut, I’ve usually regretted it a few hours later.

What was different

Godot’s autoload-singleton pattern lets the input system register itself once at project startup and be globally available. In Unity I’m using a ScriptableObject (InputReaderSO), which is conceptually similar (a single asset acting as the global hub) but it doesn’t auto-instantiate. Something has to reference it. In practice that means each scene’s input wiring picks the same asset off disk, which I actually prefer because the dependency is visible in the Inspector instead of hidden in a startup script.

Godot’s InputEvent arrives as a single stream you can subscribe to globally with _Input. Unity’s Input System hands you per-action callbacks. So on the Unity side the classifier call has to be threaded through every callback instead of sitting at one chokepoint. The line count is higher; the behaviour is the same.

Wrap-up

The moving parts are small. A static classifier with a deadzone-aware threshold. One field with a change event. Consumers that subscribe instead of polling. None of it felt clever in isolation. What helped me most was treating the active device as a push signal in both engines, and not merging it with the larger input-state enum on the Unity side.

Having the same shape in two projects means a bit less re-orientation when I jump between them. I’ll take it.

https://github.com/tang3cko/ReactiveSO-docs

References