08 Input – Devices and Bridges

19 min read

How to know which device the player is actually holding, react when a controller is unplugged mid-fight, and swap the layer that talks to the hardware.

Summary

Two related things live on this page. The first is devices: which physical thing the player is using right now, when that changes, and what to do when a controller vanishes mid-fight. The second is the bridge, which is the layer between Scylla and whatever is actually reading the hardware.

Device tracking is the part most projects need. ScyllaInput.Instance.LastUsedDevice and LastUsedDeviceType report what the player touched most recently, and that’s what switches the prompt glyphs from keyboard to gamepad the moment someone picks up a controller. Two events fire around that, one when a device connects and one when it disconnects, and the control scheme manager publishes ControlSchemeChangedEvent when the player moves between keyboard/mouse and gamepad. The disconnect case matters most, because a controller running out of battery mid-fight is exactly the scenario console certification requires you to handle.

The bridge is the extension point. IScyllaInputBridge defines what a source of input has to provide: a device list, a last-used device, key and mouse queries, and connect and disconnect events. UnityInputSystemBridge is the implementation that ships, wrapping Unity’s Input System. Because it is an interface, a second bridge can sit alongside it, and IInputBridgeManager decides how several of them combine: one at a time, in priority order, routed by device type, or all at once.

Most games never touch the bridge directly. It matters when you need input from somewhere Unity’s Input System does not reach: a replay file, a network stream driving a remote player, an arcade cabinet’s custom hardware, or an automated test harness pressing buttons. The bridge feeds the module’s direct device queries, last-used device tracking and control scheme detection. Actions read Unity’s Input System directly, so a bridge does not drive them.

Use devices and bridges for:

  • Switching prompt glyphs the instant the player moves between keyboard and gamepad.
  • Pausing the game and showing a reconnect prompt when a controller disconnects.
  • Telling keyboard, gamepad, touchscreen and pen apart, from a device or from a binding path.
  • Feeding recorded or synthetic input through the same path as live input, for replays and tests.
  • Supporting hardware Unity’s Input System does not know about.

Features

  • Last-used device tracking. LastUsedDevice and LastUsedDeviceType on the module report what the player actually touched most recently, which is the signal a prompt-glyph system needs.
  • Device events. InputDeviceConnectedEvent and InputDeviceDisconnectedEvent are published on the event bus, each carrying the device, its type, and its ID. InputDeviceChangedEvent is published when the last-used device changes, carrying the previous and current device and their types. It also fires when the last-used device is unplugged and tracking falls back to another device. For prompt glyphs, ControlSchemeChangedEvent is usually the better signal, since it only fires when the scheme category changes.
  • Module lifecycle events. InputEnabledEvent and InputDisabledEvent fire when input as a whole is switched on or off.
  • Device classification. InputDeviceUtil sorts a device into keyboard, mouse, gamepad, touchscreen, joystick or pen, and can do the same from a binding path string without a live device.
  • A direct-callback listener. IInputListener receives the same low-level device and enable events through a single method. Register the object with ScyllaInput and the callbacks are synchronous, on the main thread.
  • A swappable input backend. IScyllaInputBridge is the contract for anything that supplies input, so Unity’s Input System is one implementation rather than a hard dependency.
  • Multiple bridges at once. IInputBridgeManager registers several bridges and combines them in one of four query modes, with per-bridge priorities.
  • Per-device-type routing. A bridge can be nominated for one device type, so a custom gamepad driver handles pads while the standard bridge keeps the keyboard.

Device types

InputDeviceType is the classification everything else keys off. It is deliberately coarse: what matters for glyphs and prompts is the family, not the exact model.

ValueCoversNotes
UnknownAnything unclassifiedWhat you get from LastUsedDeviceType before the player has touched anything, and from a device the bridge does not recognize. Treat it as “show the default prompts” rather than as an error.
KeyboardKeyboardPaired with Mouse in practice. InputDeviceUtil.IsKeyboardOrMouse exists because the two almost always want the same prompt set.
MouseMouseSeparate from keyboard at the device level, though a control scheme usually treats them as one.
GamepadStandard gamepadsThe common console and PC pad. IsGamepadOrJoystick groups this with Joystick for the “show button glyphs” decision.
TouchscreenTouch inputIsTouchDevice is the check. Worth branching on for control layout as well as prompts, since touch usually needs on-screen controls rather than glyph hints.
JoystickFlight sticks and other joysticksDistinct from Gamepad because the control layout differs, even though both want button prompts rather than key names.
PenStylus and pen inputRare in games, present for completeness and for tablet-targeted tools.

InputDeviceUtil.GetDeviceTypeFromPath does the same classification from a binding path string such as <Gamepad>/buttonSouth, so you can classify a binding rather than a live device, for example while building a rebinding screen before any device has been used.

Bridge query modes

When more than one bridge is registered, InputBridgeQueryMode decides how the manager combines them. With a single bridge, which is the default state, the mode does not matter.

ModeBehaviorNotes
SingleBridgeOnly the active bridge answersThe simplest arrangement and the right default. Whichever bridge is active handles everything; the others are registered but idle. Use this when a second bridge is a replacement rather than a supplement, such as a replay bridge that takes over entirely during playback.
PriorityChainBridges are asked in priority order until one answersEach bridge gets a chance, highest priority first, and the first to report input wins. Suits a setup where a specialized bridge handles what it knows and falls through to the standard one for everything else.
PerInputTypeEach device type is routed to a nominated bridgeSet with SetBridgeForInputType. The arrangement for custom hardware: a bespoke bridge owns Gamepad while UnityInputSystemBridge keeps keyboard and mouse. Types with no nomination fall back to the active bridge.
CombineAllEvery bridge is queried and the results mergedAll bridges contribute, which suits two genuine sources of input that should both work at once, such as a local player on a pad and a networked player on a stream. Costs the most, since every query touches every bridge.

InputConflictResolution on the manager decides what happens when two bridges report the same input in the combining modes.

Recipes

Each recipe stands alone. The first three cover what most projects need; the rest are for the cases where the bridge is worth reaching for.

Switch prompt glyphs when the player picks up a controller

Problem. Your tutorial says “press Space to jump” and the player just put down the keyboard and picked up a pad. The prompt needs to become a button glyph without the player doing anything to tell you.

Solution. LastUsedDeviceType reports what was touched most recently, and ControlSchemeChangedEvent fires when the player moves between keyboard/mouse and gamepad.

private ScyllaEventSubscription _schemeChanged;

private void OnEnable()
{
    /* Fires when the active control scheme switches, carrying the previous and current
       scheme types and the device that triggered the switch. */
    _schemeChanged = ScyllaEvents.Listen<ControlSchemeChangedEvent>(this, OnSchemeChanged);

    /* Set the initial state too. The event only fires on a change, so a player who
       started on a pad has not raised one yet. */
    RefreshPrompts(ScyllaInput.Instance.LastUsedDeviceType);
}

private void OnDisable()
{
    _schemeChanged?.Dispose();
}

private void OnSchemeChanged(ControlSchemeChangedEvent evt)
{
    /* TriggeringDevice is null for a switch made from code, so fall back to the
       module's own last-used device in that case. */
    var deviceType = evt.TriggeringDevice != null
        ? InputDeviceUtil.GetDeviceType(evt.TriggeringDevice)
        : ScyllaInput.Instance.LastUsedDeviceType;

    RefreshPrompts(deviceType);
}

private void RefreshPrompts(InputDeviceType deviceType)
{
    /* A glyph decision wants the coarse classification: a flight stick and a pad
       both show button prompts, and Unknown means fall back to the default set. */
    _promptSet = deviceType switch
    {
        InputDeviceType.Gamepad or InputDeviceType.Joystick => _gamepadPrompts,
        InputDeviceType.Touchscreen                         => _touchPrompts,
        _                                                   => _keyboardPrompts,
    };
}

Pause the game when a controller disconnects

Problem. A controller runs out of battery in the middle of a boss fight. The player cannot do anything, and the boss carries on hitting them. Console certification requires you to handle this, and players notice on every platform.

Solution. InputDeviceDisconnectedEvent carries the device that went away, so you can check whether it was the one being used.

private void OnEnable()
{
    _disconnected = ScyllaEvents.Listen<InputDeviceDisconnectedEvent>(this, OnDeviceLost);
    _connected    = ScyllaEvents.Listen<InputDeviceConnectedEvent>(this, OnDeviceFound);
}

private void OnDeviceLost(InputDeviceDisconnectedEvent evt)
{
    /* DeviceID identifies which device went, so a second pad disconnecting in a
       local-multiplayer game does not pause player one. */
    if (evt.DeviceID != _activeDeviceID)
    {
        return;
    }

    /* Pause through the framework's time subsystem so everything else honors it. */
    ScyllaTime.Instance.Pause();
    _reconnectPrompt.Show($"Please reconnect your {evt.DeviceType}.");
}

private void OnDeviceFound(InputDeviceConnectedEvent evt)
{
    /* Do not auto-resume: the player may have set the pad down deliberately. Show
       that it is back and let them dismiss the prompt. */
    if (evt.DeviceType == InputDeviceType.Gamepad)
    {
        _reconnectPrompt.ShowResumeButton();
    }
}

Tell devices apart from a binding path

Problem. You are building a rebinding screen and want a gamepad glyph next to gamepad bindings, but the player has not touched a pad yet so there is no live device to inspect.

Solution. GetDeviceTypeFromPath classifies the binding string itself.

foreach (var binding in action.bindings)
{
    /* Works on the path string, so no device needs to be connected. "<Gamepad>/
       buttonSouth" classifies as Gamepad, "<Keyboard>/space" as Keyboard. */
    var deviceType = InputDeviceUtil.GetDeviceTypeFromPath(binding.path);

    row.Icon = _glyphs.ForDeviceType(deviceType);   /* your own glyph lookup */
}

/* The live-device forms, for when you do have one. The grouped checks exist because
   most decisions actually want the grouping, not the exact device type. */
var device = ScyllaInput.Instance.LastUsedDevice;

if (InputDeviceUtil.IsKeyboardOrMouse(device))
{
    ShowKeyboardLayoutHint();
}
else if (InputDeviceUtil.IsGamepadOrJoystick(device))
{
    ShowButtonLayoutHint();
}
else if (InputDeviceUtil.IsTouchDevice(device))
{
    EnableOnScreenControls();
}

/* And the precise classification when the family is not enough. */
var type = InputDeviceUtil.GetDeviceType(device);

React to device events without managing subscriptions

Problem. Several systems each care about device connect and disconnect, and each one is carrying its own subscribe, unsubscribe and dispose boilerplate.

Solution. IInputListener is a single-method interface receiving the same low-level events. Implement it, register the object once, and one method handles them all instead of one subscription each.

/* One method, one enum, one device. InputEventType declares DeviceConnected,
   DeviceDisconnected, DeviceChanged, InputEnabled and InputDisabled. DeviceChanged
   arrives with the new last-used device whenever the player switches devices. */
public class ControllerWatcher : MonoBehaviour, IInputListener
{
    private void OnEnable()
    {
        /* Implementing the interface is not enough on its own: the object has to be
           registered. Duplicate registrations are ignored, so this is safe to repeat. */
        ScyllaInput.Instance.RegisterListener(this);
    }

    private void OnDisable()
    {
        /* The module holds the reference, so an unregistered listener on a destroyed
           object is a stale callback waiting to happen. */
        ScyllaInput.Instance.UnregisterListener(this);
    }

    public void OnInputEvent(InputEventType eventType, InputDevice device)
    {
        /* device is null for InputEnabled and InputDisabled, which are not tied to any
           particular piece of hardware. Guard before dereferencing it. */
        switch (eventType)
        {
            case InputEventType.DeviceDisconnected:
                _hud.ShowControllerWarning(device);
                break;

            case InputEventType.DeviceConnected:
                _hud.ClearControllerWarning();
                break;

            case InputEventType.InputDisabled:
                /* Input as a whole was switched off, during a cutscene or a load.
                   Distinct from a device going away. */
                _hud.HidePrompts();
                break;
        }
    }
}

Know when input is switched off entirely

Problem. A cutscene disables input, and your HUD keeps showing “press A to interact” prompts for controls that currently do nothing.

Solution. InputEnabledEvent and InputDisabledEvent bracket the module being switched on and off. Neither carries a payload, because there is nothing to say beyond the fact itself.

_inputDisabled = ScyllaEvents.Listen<InputDisabledEvent>(this, _ => _prompts.Hide());
_inputEnabled  = ScyllaEvents.Listen<InputEnabledEvent>(this, _ => _prompts.Show());

Feed recorded input through the live input path

Problem. You want a replay system, or an automated test that plays through a level, and you do not want a second code path where recorded input is handled differently from real input. Anything that only happens on the real path is a bug you will not catch.

Solution. Implement IScyllaInputBridge and register it. Everything that reads through the bridge sees it as ordinary input: the module’s direct queries such as ScyllaInput.Instance.IsKeyPressed and MousePosition, last-used device tracking, and control scheme detection. Actions, and therefore contexts and buffering, read Unity’s Input System directly, so recorded input that should drive actions has to be fed through the Input System itself.

/* A bridge supplies devices, a last-used device, key and mouse state, and the two
   device events. Implementing it puts your source on the same footing as Unity's. */
public class ReplayBridge : IScyllaInputBridge
{
    public string BridgeID  => "Replay";
    public int    Priority  => 100;          /* Higher wins in PriorityChain mode. */
    public bool   IsAvailable => _playing;

    public event Action<InputDevice> OnDeviceConnected;
    public event Action<InputDevice> OnDeviceDisconnected;

    public void Initialize() { _frames = LoadRecording(); }
    public void Shutdown()   { _frames = null; }

    public void Update()
    {
        /* Advance the recording by one frame. Everything downstream reads the state
           this sets, so the replay drives the game exactly as a player would. */
        _current = _frames[_frameIndex++];
    }

    public bool IsKeyPressed(Key key) => _current.Keys.Contains(key);
    public bool WasKeyPressedThisFrame(Key key) => _current.Pressed.Contains(key);
    /* ... the rest of the contract ... */
}

/* Register it, then decide how it combines with the standard bridge. SingleBridge
   means the replay takes over completely while it is active. */
var manager = ScyllaInput.Instance.BridgeManager;

manager.RegisterBridge(new ReplayBridge());
manager.QueryMode = InputBridgeQueryMode.SingleBridge;

Route one device type to custom hardware

Problem. Your arcade cabinet has a custom controller board that Unity’s Input System knows nothing about, but the keyboard and mouse should keep working normally for the operator menu.

Solution. PerInputType mode routes each device type to a nominated bridge.

var manager = ScyllaInput.Instance.BridgeManager;

manager.RegisterBridge(new CabinetHardwareBridge());

/* Each device type can name a bridge. Anything not nominated falls through to the
   active bridge, so the keyboard keeps working with no extra configuration. */
manager.QueryMode = InputBridgeQueryMode.PerInputType;
manager.SetBridgeForInputType(InputDeviceType.Gamepad, "CabinetHardware");
manager.SetBridgeForInputType(InputDeviceType.Joystick, "CabinetHardware");

/* The routing is inspectable and reversible at runtime. */
if (manager.HasBridgeForInputType(InputDeviceType.Gamepad))
{
    var owner = manager.GetBridgeForInputType(InputDeviceType.Gamepad);
}

manager.ClearBridgeForInputType(InputDeviceType.Joystick);

/* Priority matters in PriorityChain mode and can be changed after registration. */
manager.SetBridgePriority("CabinetHardware", 200);

Inspect what is currently supplying input

Problem. Something is wrong with input on one machine and you need to know which bridge is answering before you can debug further.

Solution. The manager exposes the active bridge and the full registration list.

var manager = ScyllaInput.Instance.BridgeManager;

/* Which one is answering right now. */
Log.Info($"Active bridge: {manager.ActiveBridge?.BridgeID ?? "none"}", LogCategory.Input);

/* Every registered bridge and whether it is currently able to supply input.
   IsAvailable is the bridge's own report, so a replay bridge says false when it is
   not playing back. */
foreach (var bridge in manager.Bridges)
{
    Log.Info($"  {bridge.BridgeID} priority={bridge.Priority} available={bridge.IsAvailable}",
        LogCategory.Input);
}

/* Look one up by ID, for tooling that targets a known bridge. */
if (manager.HasBridge("Replay"))
{
    var replay = manager.GetBridge("Replay");
}

/* And remove one when it is finished, such as a replay bridge after playback ends. */
manager.UnregisterBridge("Replay");

API Sketch

Device access hangs off the module; the bridge contracts are the extension point.

/* --- On the module --- */
InputDevice        ScyllaInput.Instance.LastUsedDevice { get; }
InputDeviceType    ScyllaInput.Instance.LastUsedDeviceType { get; }
IInputBridgeManager ScyllaInput.Instance.BridgeManager { get; }

/* --- Device classification --- */
static class InputDeviceUtil {
    InputDeviceType GetDeviceType(InputDevice device);
    bool            IsKeyboardOrMouse(InputDevice device);
    bool            IsGamepadOrJoystick(InputDevice device);
    bool            IsTouchDevice(InputDevice device);
    InputDeviceType GetDeviceTypeFromPath(string bindingPath);
}

enum InputDeviceType { Unknown, Keyboard, Mouse, Gamepad, Touchscreen, Joystick, Pen }

/* --- Events, published on the SEX (Scylla Event eXchange) bus --- */
class InputDeviceConnectedEvent    { InputDevice Device; InputDeviceType DeviceType; int DeviceID; }
class InputDeviceDisconnectedEvent { InputDevice Device; InputDeviceType DeviceType; int DeviceID; }
/* Published when the last-used device changes, including the fallback after it is unplugged. */
class InputDeviceChangedEvent      { InputDevice PreviousDevice, CurrentDevice;
                                     InputDeviceType PreviousDeviceType, CurrentDeviceType; }
class InputEnabledEvent  { }   /* no payload */
class InputDisabledEvent { }   /* no payload */

/* --- Direct callback alternative --- */
interface IInputListener {
    void OnInputEvent(InputEventType eventType, InputDevice device);  /* device may be null */
}

/* Implementing is not enough; the object has to be registered. */
void ScyllaInput.Instance.RegisterListener(IInputListener listener);
void ScyllaInput.Instance.UnregisterListener(IInputListener listener);

enum InputEventType { DeviceConnected, DeviceDisconnected, DeviceChanged,
                      InputEnabled, InputDisabled }

/* --- The bridge contract --- */
interface IScyllaInputBridge {
    string BridgeID { get; }
    int    Priority { get; }
    bool   IsAvailable { get; }

    void Initialize();
    void Shutdown();
    void Update();

    IReadOnlyList<InputDevice> GetDevices();
    InputDevice     GetLastUsedDevice();
    InputDeviceType GetDeviceType(InputDevice device);

    event Action<InputDevice> OnDeviceConnected;
    event Action<InputDevice> OnDeviceDisconnected;

    bool    IsKeyPressed(Key key);
    bool    WasKeyPressedThisFrame(Key key);
    bool    WasKeyReleasedThisFrame(Key key);
    bool    IsAnyKeyPressed();
    Vector2 GetMousePosition();
    Vector2 GetMouseDelta();
    Vector2 GetMouseScrollDelta();
    bool    IsMouseButtonPressed(int button);
    bool    WasMouseButtonPressedThisFrame(int button);
    bool    WasMouseButtonReleasedThisFrame(int button);
    bool    IsMouseConnected();

    bool    IsGamepadConnected();
    Gamepad GetCurrentGamepad();
    bool    IsGamepadButtonPressed(GamepadButton button);
    bool    WasGamepadButtonPressedThisFrame(GamepadButton button);
    bool    WasGamepadButtonReleasedThisFrame(GamepadButton button);
    Vector2 GetGamepadLeftStick();
    Vector2 GetGamepadRightStick();
    float   GetGamepadLeftTrigger();
    float   GetGamepadRightTrigger();
}

/* --- The manager --- */
interface IInputBridgeManager {
    IScyllaInputBridge              ActiveBridge { get; }
    IReadOnlyList<IScyllaInputBridge> Bridges { get; }
    InputBridgeQueryMode            QueryMode { get; set; }
    InputConflictResolution         ConflictResolution { get; set; }

    void RegisterBridge(IScyllaInputBridge bridge);
    void UnregisterBridge(string bridgeID);
    IScyllaInputBridge GetBridge(string bridgeID);
    bool HasBridge(string bridgeID);

    void SetBridgePriority(string bridgeID, int priority);
    int  GetBridgePriority(string bridgeID);

    void   SetBridgeForInputType(InputDeviceType inputType, string bridgeID);
    string GetBridgeForInputType(InputDeviceType inputType);
    void   ClearBridgeForInputType(InputDeviceType inputType);
    bool   HasBridgeForInputType(InputDeviceType inputType);

    void Initialize();
    void Shutdown();
    void Update();

    /* Device management and the same input queries as a bridge, answered through
       the bridges according to QueryMode. */
    IReadOnlyList<InputDevice> GetAllDevices();
    InputDevice     GetLastUsedDevice();
    InputDeviceType GetDeviceType(InputDevice device);
    event Action<InputDevice> OnDeviceConnected;
    event Action<InputDevice> OnDeviceDisconnected;
}

enum InputBridgeQueryMode { SingleBridge, PriorityChain, PerInputType, CombineAll }

/* The shipped implementation, wrapping Unity's Input System. */
class UnityInputSystemBridge : IScyllaInputBridge { }

See the API reference for full signatures, remarks, and exception contracts on every member.

Best Practices

  • Drive prompts from LastUsedDeviceType, not from what is connected. A player with a pad plugged in who is typing on the keyboard wants keyboard prompts, and connection state cannot tell you that.
  • Set the initial prompt state as well as subscribing. ControlSchemeChangedEvent only fires on a change, so a player who started on a gamepad has raised nothing yet.
  • Handle disconnect, and do not auto-resume on reconnect. The pause is required on console and welcome everywhere; resuming without the player asking drops them back into whatever was hitting them.
  • Check DeviceID before reacting to a disconnect. In local multiplayer, one pad going flat should not pause the other player.
  • Use the grouped checks rather than switching on every enum value. IsGamepadOrJoystick exists because a flight stick and a pad want the same prompts, and a switch that forgets Joystick is a bug waiting for the one player who owns one.
  • Reach for GetDeviceTypeFromPath when there is no live device. A rebinding screen has bindings before it has devices, and the path carries the classification already.
  • Treat Unknown as “show defaults”, not as an error. It is the honest answer before the player has touched anything, and it will occur on the first frame of every session.
  • Leave the bridge alone unless you need a second input source. The shipped Unity bridge covers everything a normal game does; the extension point exists for replays, networked input and custom hardware.
  • Give a bridge a stable BridgeID. Registration, lookup, priority and per-type routing all key on that string, so changing it later breaks every call site that named it.
  • Snapshot bridge state once per Update. Everything above the bridge assumes its answers are consistent within a frame; a bridge that reads live hardware per query produces bugs that look like they live in the action layer.
  • Prefer SingleBridge unless you genuinely need combining. It is the cheapest, and a replay or test bridge usually wants to replace live input rather than supplement it.
  • Unregister a bridge when its job ends. A replay bridge left registered after playback keeps being queried, and IsAvailable returning false is a weaker guarantee than not being in the list.

Pitfalls