Actions and Bindings

24 min read

The action layer for registering assets, reading move and jump values from gameplay code, reacting to action transitions on the event bus, and building runtime prompt labels from binding data.

Summary

This page covers the action layer in detail: registering an InputActionAsset with Scylla’s action manager, looking actions up, reading their current values, subscribing to action transitions on the SEX (Scylla Event eXchange) bus, and inspecting bindings at runtime for UI prompts and configuration screens. Three wrapper types do the heavy lifting (ScyllaInputActionMap, ScyllaInputAction, ScyllaInputBinding), all owned by IInputActionManager and reached through ScyllaInput.Instance.ActionManager.

Unity’s Input System ships with InputActionAsset, InputActionMap, InputAction, and InputBinding types that handle parsing, devices, and the binding evaluation pipeline correctly. What it does not ship is a coherent answer to four day-to-day questions a real game asks: what is the single shape for “read a value or react to a transition”, how do action callbacks compose with the event bus that the rest of the game already subscribes to, how do modifier-key bindings (“Ctrl+Z”) suppress the bare-key bindings on the same trigger (“Z”) that would otherwise fire together, and what is the supported path for showing the player’s current binding as a UI prompt without reaching into Unity’s path strings. Scylla Input’s action layer answers all four.

The wrapper triad maps cleanly onto Unity’s types. A ScyllaInputActionMap wraps an InputActionMap and exposes enable/disable plus the contained actions. A ScyllaInputAction wraps an InputAction and adds the value-reading helpers, the per-frame state queries, and the suppression hook that drives modifier disambiguation. A ScyllaInputBinding wraps each entry in InputAction.bindings and exposes the override state, display string, group membership, and detected device type. The wrappers add behaviour without hiding what is underneath: every ScyllaInputAction exposes its UnityAction, every ScyllaInputBinding exposes its UnityBinding, so escape hatches into the Unity API are always available when something more specialised is needed.

The two read paths exist because game code has two shapes of “I want input”. Per-frame reads (movement, look, aim) live in Update and want the action’s current value; polling via ReadVector2 / ReadFloat / WasPerformedThisFrame is the right answer. One-shot transitions (jump, attack, interact, weapon swap) want to react when the action fires without polling; subscribing to InputActionPerformedEvent on the bus is the right answer for those. Both paths are first-class and can coexist on the same action.

Use this page for:

  • Registering an InputActionAsset and looking actions up by ID.
  • Reading continuous values (movement, look) from Update via ReadVector2, ReadFloat, ReadValue<T>.
  • Reading one-shot transitions (jump, attack) via WasPerformedThisFrame (polling) or InputActionPerformedEvent (events).
  • Configuring composite bindings with ButtonWithOneModifier / ButtonWithTwoModifiers and relying on the framework’s automatic disambiguation against the bare-key action.
  • Building composite Vector2 bindings (WASD, IJKL) that share an action.
  • Iterating bindings to render the player’s current key/button assignment as a UI prompt.
  • Constructing action maps programmatically when bindings cannot live in an InputActionAsset (test rigs, generated UI, runtime-authored gameplay modes).

Features

  • Wrapper triad: ScyllaInputActionMap, ScyllaInputAction, ScyllaInputBinding. Each wraps a Unity type, adds behaviour without hiding the underlying object, and is owned by IInputActionManager. The Unity-side type is always reachable through a public property (UnityMap, UnityAction, UnityBinding) when an escape hatch is needed.
  • Two registration paths. RegisterActionAsset(InputActionAsset) wraps every map and action inside the asset automatically; RegisterActionMap(ScyllaInputActionMap) accepts a programmatically constructed map for tests, generated UI, or runtime-authored modes. Both feed the same lookup, value-read, and event surface.
  • Two read paths. Polling (ActionManager.IsActionPressed("Jump"), ActionManager.GetActionVector2("Move"), action.WasPerformedThisFrame) for per-frame reads in Update; events (ScyllaEvents.Listen<InputActionPerformedEvent>(...)) for one-shot transitions reached from anywhere in the codebase.
  • Three value-read methods plus a safe variant. ReadFloat and ReadVector2 dispatch on the action’s expected control type so a misconfigured action returns zero instead of throwing. ReadValue<T> is the raw passthrough; ReadValueSafe<T> catches InvalidOperationException for callers that cannot guarantee the type match.
  • Modifier-aware disambiguation, automatic. When Ctrl+Z and Z are both bound, pressing Ctrl+Z fires only the modifier-bound action; the bare Z action is silently suppressed for the duration. Computed at registration time by walking each action’s binding list; runtime cost is one boolean check on the suppression-flagged actions only.
  • Per-frame state queries. IsPressed, WasStartedThisFrame, WasPerformedThisFrame, WasCanceledThisFrame available both as ScyllaInputAction properties (for cached references) and as IInputActionManager methods that take an actionID (for one-off polling without caching). Mirrors Unity’s InputAction API surface exactly.
  • Typed action events on the bus. InputActionPerformedEvent, InputActionStartedEvent, InputActionCanceledEvent carry ActionID, MapID, Value, Vector2Value, Device, and Time. Subscribers filter by ActionID and get the payload without needing to query the action manager themselves.
  • Per-action / per-map / global enable control. Disable a single action via action.Disable(), a whole map via ActionManager.DisableActionMap("Gameplay"), every map at once via ActionManager.DisableAllActionMaps(). The right granularity for everything from a temporary cooldown to a full modal pause.
  • Binding introspection for UI. Each ScyllaInputBinding exposes the design-time Path, the runtime EffectivePath (with overrides), a precomputed DisplayString (“Space”, “South Button”), the control scheme Groups, and a GetDeviceType() classifier. Build per-action prompt rows or per-control-scheme columns from this data.

Concepts

The vocabulary used throughout the rest of the page. Each row is a type or a term that shows up repeatedly in the recipes below.

ConceptType / shapeNotes
InputActionAssetUnity Input System assetEditor-authored container of action maps. Registered with Scylla via ActionManager.RegisterActionAsset(asset); the framework calls asset.Enable() and wraps each contained map.
ScyllaInputActionMapwrapper around InputActionMapA named group of actions (e.g. “Gameplay”, “UI”). Enable / disable as a unit. Constructed automatically from each InputActionMap inside a registered asset, or programmatically via new ScyllaInputActionMap(mapID, displayName).
ScyllaInputActionwrapper around Unity’s InputActionThe unit of “an action performed”. Exposes value-read helpers, per-frame state queries, three event hooks (OnPerformed, OnStarted, OnCanceled), and modifier-disambiguation metadata (ModifierDepth, NeedsModifierCheck).
ScyllaInputBindingsnapshot wrapper around InputBindingOne entry in the action’s flat binding list. Holds the design-time Path, the override-aware EffectivePath, a precomputed DisplayString, the control scheme Groups, and the composite flags (IsComposite, IsPartOfComposite).
Action ID resolutionstring lookup contractPlain action names work when unique across all registered maps. When two actions share a name (e.g. “Cancel” in both “UI” and “Gameplay”), the second is reachable via the fully qualified form "MapID/ActionID". TryGetAction returns false on ambiguity instead of guessing.
Modifier depthint in {0, 1, 2} per action0 for simple bindings, 1 for ButtonWithOneModifier, 2 for ButtonWithTwoModifiers. Computed at registration. Drives the suppression rule that lets Ctrl+Z coexist cleanly with Z.
Trigger control pathnormalised string per actionThe control path of the binding’s “button” part (e.g. <Keyboard>/z for both Z and Ctrl+Z). Actions sharing a trigger path compete during disambiguation; actions on different triggers (e.g. Space vs Z) are independent.
InputActionPerformedEvent and friendsScyllaEvent subtypes published by the managerFired for every action transition. Carries ActionID, MapID, Value, Vector2Value, Device, Time. Subscribers route on ActionID rather than subscribing to one event per action.

Recipes

Each recipe here is self-contained. If you are new to the action layer, start with the first recipe – it sets up the cached ScyllaInputAction references that the others reuse. The rest can be read in any order, so jump to whichever matches what you are building.

Register an asset and cache action references

Problem. You have an InputActionAsset authored in the editor with a “Move” action and a “Jump” action, and you want gameplay code to hold stable references to them without doing a dictionary lookup every frame.

Solution. Register the asset once against ActionManager, then cache the resulting ScyllaInputAction objects in Start (inside a RunWhenReady callback). The references stay valid for the lifetime of the registration; you only need to look them up again after an UnregisterActionAsset / RegisterActionAsset round-trip.

using Scylla.Core;
using Scylla.Input;
using UnityEngine;
using UnityEngine.InputSystem;

public sealed class PlayerInputBootstrap : MonoBehaviour
{
    [SerializeField] private InputActionAsset _playerActions;

    /* Stable references cached at startup. The framework holds these for the
     * lifetime of the asset registration; re-lookup is only needed after an
     * UnregisterActionAsset / RegisterActionAsset round trip. */
    private ScyllaInputAction _moveAction;
    private ScyllaInputAction _jumpAction;
    private ScyllaInputAction _inventoryAction;
    private ScyllaInputAction _hotkeyAction;
    private ScyllaInputAction _debugAction;

    private void Start()
    {
        /* RunWhenReady fires immediately if the framework is already up, or
         * queues until readiness if not - the safe entry point for any code
         * that needs the Input module. */
        ScyllaCore.Instance.RunWhenReady(OnFrameworkReady);
    }

    private void OnFrameworkReady()
    {
        var actions = ScyllaInput.Instance.ActionManager;

        /* Registering the asset calls asset.Enable() on the Unity side and
         * wraps every InputActionMap inside it into a ScyllaInputActionMap.
         * Registering the same asset twice is a no-op (a warning is logged). */
        actions.RegisterActionAsset(_playerActions);

        /* GetAction resolves on the plain action name first. If the name has no
         * slash and no exact match exists, every registered map is searched.
         * Use the fully qualified "MapID/ActionID" form to pin the lookup to one
         * specific map when two maps share an action name. */
        _moveAction      = actions.GetAction("Move");
        _jumpAction      = actions.GetAction("Jump");
        _inventoryAction = actions.GetAction("Inventory");
        _hotkeyAction    = actions.GetAction("Gameplay/Hotkey");

        /* TryGetAction is the no-throw overload - the right shape for optional
         * bindings (a debug action that may or may not exist in the shipped asset). */
        if (actions.TryGetAction("OptionalDebugAction", out var debugAction))
        {
            _debugAction = debugAction;
        }
    }
}

Read movement and react to jump from a player controller

Problem. You are building a platformer player controller. “Move” is a continuous Vector2 composite (WASD or left stick) that you need every frame to drive locomotion. “Jump” is a one-shot button that you want to fire exactly once per press – polling for it in Update works, but you also want the audio system and the animation controller to react without needing a reference to the PlayerController.

Solution. Use both read paths on the same actions. Read “Move” by polling in Update with ReadVector2. For “Jump”, subscribe to InputActionPerformedEvent on the Event System bus: any system in the codebase can react without knowing about the controller.

using Scylla.Core.Events;
using Scylla.Input;
using UnityEngine;

public sealed class PlayerController : MonoBehaviour
{
    [SerializeField] private float _moveSpeed = 5f;

    /* Cached from the registration recipe above. */
    private ScyllaInputAction _moveAction;
    private ScyllaInputAction _jumpAction;

    /* Subscription token for the event-driven path. Field-store with lifetime
     * matching this component; dispose in OnDestroy. */
    private ScyllaEventSubscription _jumpSubscription;

    private void Start()
    {
        ScyllaCore.Instance.RunWhenReady(() =>
        {
            var actions = ScyllaInput.Instance.ActionManager;
            _moveAction = actions.GetAction("Move");
            _jumpAction = actions.GetAction("Jump");

            /* Event subscription for Jump. The handler fires once on the rising
             * edge; no per-frame check needed. The bus delivers in priority order. */
            _jumpSubscription = ScyllaEvents.Listen<InputActionPerformedEvent>(
                subscriber: this,
                handler:    OnAnyActionPerformed,
                priority:   ScyllaEventPriority.Medium
            );
        });
    }

    private void OnDestroy()
    {
        /* Dispose the token. Missing this is the most common cause of "handler
         * fired after the scene reloaded" bugs. */
        _jumpSubscription?.Dispose();
    }

    private void Update()
    {
        if (_moveAction == null) return;

        /* Continuous read: ReadVector2 returns the composite WASD value (or analog
         * stick). Returns Vector2.zero when no binding is held. Safe on type mismatch:
         * if Move were misconfigured as a Button action, ReadVector2 returns zero
         * instead of throwing. */
        Vector2 move = _moveAction.ReadVector2();
        transform.Translate(move * _moveSpeed * UnityEngine.Time.deltaTime);

        /* The polling alternative for Jump - valid if you want all input handling
         * in one Update block rather than spread across subscriptions. */
        if (_jumpAction != null && _jumpAction.WasPerformedThisFrame)
        {
            ExecuteJump();
        }
    }

    private void OnAnyActionPerformed(InputActionPerformedEvent evt)
    {
        /* One handler dispatching on ActionID is faster and clearer than multiple
         * subscriptions filtering through type checks. */
        if (evt.ActionID == "Jump")
        {
            ExecuteJump();
        }
    }

    private void ExecuteJump() { /* ... */ }
}

Disambiguate modifier combos (Shift+Key vs bare Key)

Problem. You want to bind “Toggle Console” to backquote and “Toggle Performance Monitor” to Shift+Backquote. With Unity’s Input System alone, pressing Shift+Backquote fires both actions because ButtonWithOneModifier composites do not suppress the bare-key binding on the same trigger. You get double-fire on every modifier combo.

Solution. Scylla solves this automatically at registration time. No configuration is required. When your asset contains the three bindings below, the framework walks each action’s binding list, computes the modifier depth (0 for a simple binding, 1 for ButtonWithOneModifier, 2 for ButtonWithTwoModifiers), and groups actions that share a trigger control path. At callback time, an action with lower modifier depth is suppressed when a sibling with higher depth is satisfiable given the currently held modifiers.

/* Three actions on the same trigger key (Backquote):

   Action: "ToggleConsole"        Binding: <Keyboard>/backquote                     -> depth 0
   Action: "TogglePerfMonitor"    Binding: Shift + <Keyboard>/backquote             -> depth 1
   Action: "CycleVisualStyle"     Binding: Ctrl + Shift + <Keyboard>/backquote      -> depth 2

   Trigger control path for all three: "<Keyboard>/backquote". The framework
   groups them at registration and flags the two lower-depth actions for the
   runtime suppression check.

   At runtime:
     Press Backquote alone       -> only ToggleConsole fires
     Press Shift+Backquote       -> only TogglePerfMonitor fires; ToggleConsole is suppressed
     Press Ctrl+Shift+Backquote  -> only CycleVisualStyle fires; the other two are suppressed

   Actions on unrelated keys (Jump on Space, Move on WASD) are never affected
   because they have no competing siblings on the same trigger path.

   OnCanceled always fires regardless of suppression so subscribers that track
   pressed/held state correctly reset themselves even when the corresponding
   OnStarted was suppressed. */

The suppression decision flows through ScyllaInputAction.ShouldSuppress, an internal delegate injected by the action manager. The runtime cost is paid only by actions with the NeedsModifierCheck flag set, which the manager sets only on actions that have at least one sibling with a higher modifier depth on the same trigger path. Actions on a unique trigger – which is most actions – pay no runtime cost at all.

Build runtime button-prompt labels

Problem. You are building an in-world “Press [E] to interact” label and an options screen that shows the player’s current key assignments. You need the display string for an action’s active binding without digging into Unity’s InputControlPath strings yourself, and you need to know which device family the binding targets so you can pick the right prompt sprite atlas.

Solution. Each ScyllaInputBinding exposes a precomputed DisplayString for the most common display case and a GetDeviceType() classifier. GetBindingsForGroup filters down to the active control scheme so you get the right binding when the player is on a gamepad versus keyboard. For the custom-binding indicator (a “you changed this” asterisk in the options UI), HasOverride tells you whether EffectivePath differs from the design-time Path.

using Scylla.Input;
using UnityEngine;
using UnityEngine.InputSystem;

public sealed class PromptResolver
{
    private readonly IInputActionManager _actions;

    public PromptResolver(IInputActionManager actions)
    {
        _actions = actions;
    }

    /* Returns the first binding's display label for the named action, filtered to
     * the requested control scheme group. Call this from UI build code:
     *   var label = resolver.GetPromptLabel("Interact", "Keyboard");
     *   var pad   = resolver.GetPromptLabel("Interact", "Gamepad");
     * Returns null when no matching binding exists. */
    public string GetPromptLabel(string actionID, string group)
    {
        var action = _actions.GetAction(actionID);
        if (action == null) return null;

        /* GetBindingsForGroup does a case-insensitive substring match against each
         * binding's semicolon-separated Groups string. Allocates a list per call;
         * cache the result outside Update (see Best Practices). */
        var bindings = action.GetBindingsForGroup(group);
        if (bindings.Count == 0) return null;

        /* DisplayString is precomputed with OmitDevice, so it reads as a short label
         * ("E") rather than "Keyboard/E". Call binding.GetDisplayString(options) on
         * demand for formatting with explicit options. */
        return bindings[0].DisplayString;
    }

    /* Returns the device type that the first binding for an action targets.
     * Use this to choose prompt sprite atlases (keyboard vs Xbox vs PlayStation). */
    public InputDeviceType GetPromptDevice(string actionID)
    {
        var action = _actions.GetAction(actionID);
        if (action == null || action.BindingCount == 0)
            return InputDeviceType.Unknown;

        return action.GetBinding(0).GetDeviceType();
    }

    /* HasOverride is the hook for a "you customised this binding" indicator.
     * The design-time Path vs runtime EffectivePath difference drives the indicator. */
    public bool HasPlayerOverride(string actionID)
    {
        var action = _actions.GetAction(actionID);
        if (action == null) return false;

        foreach (var binding in action.Bindings)
        {
            if (binding.HasOverride) return true;
        }
        return false;
    }
}

Bind multiple keys or devices to the same action

Problem. You want “Jump” to fire on both the spacebar (keyboard players) and the South button (gamepad players) without writing separate handling for each device. You want both bindings to feed the same ScyllaInputAction and the same event on the bus.

Solution. Author both bindings in the same action in the InputActionAsset editor. The action wraps them both; whichever device the player uses, the same ScyllaInputAction.WasPerformedThisFrame and InputActionPerformedEvent fire. The Device field in the event payload tells you which physical device triggered it when you need to branch (for example, to show the correct prompt icon).

/* In the InputActionAsset editor, "Jump" has two bindings:
 *   Binding 0: <Keyboard>/space         (Groups: "Keyboard")
 *   Binding 1: <Gamepad>/buttonSouth    (Groups: "Gamepad")
 *
 * Both are wrapped into the same ScyllaInputAction; no code change is needed
 * to support both. */

using Scylla.Core.Events;
using Scylla.Input;
using UnityEngine;
using UnityEngine.InputSystem;

public sealed class JumpHandler : MonoBehaviour
{
    private ScyllaEventSubscription _jumpSub;

    private void Start()
    {
        ScyllaCore.Instance.RunWhenReady(() =>
        {
            _jumpSub = ScyllaEvents.Listen<InputActionPerformedEvent>(
                subscriber: this,
                handler:    OnActionPerformed,
                priority:   ScyllaEventPriority.Medium
            );
        });
    }

    private void OnDestroy()
    {
        _jumpSub?.Dispose();
    }

    private void OnActionPerformed(InputActionPerformedEvent evt)
    {
        if (evt.ActionID != "Jump") return;

        /* evt.Device tells you what triggered it. Branch here only if the
         * response actually differs per device (e.g. different haptic feedback). */
        var isGamepad = evt.Device is Gamepad;

        ExecuteJump(isGamepad);
    }

    private void ExecuteJump(bool fromGamepad) { /* ... */ }
}

Construct action maps in code for test rigs and runtime modes

Problem. You have a test fixture that needs to inject synthetic input without an InputActionAsset file, or a runtime gameplay mode that generates its bindings at startup from a data file. You want these bindings to participate in the same modifier-disambiguation rules and the same event bus as the editor-authored actions.

Solution. ScyllaInputActionMap exposes a string constructor that builds an empty, unattached map. Add actions one by one, register the map, and from that point it behaves identically to a map wrapped from an asset. Note that programmatic maps start disabled; call Enable() when the actions should start receiving input.

using Scylla.Input;
using UnityEngine.InputSystem;

public static class TestInputBuilder
{
    /* Builds an action map programmatically for a test or a generated gameplay mode.
     * The Unity InputAction is constructed directly; the ScyllaInputAction wrapper
     * is created from it and added to the new map. Once registered, the map's actions
     * behave identically to ones loaded from an asset. */
    public static ScyllaInputActionMap BuildTestMap(IInputActionManager actions)
    {
        /* Empty map with an explicit ID and display name. */
        var map = new ScyllaInputActionMap(mapID: "Test", displayName: "Test Map");

        /* Construct a Unity InputAction with a button binding. The expectedControlType
         * drives the value-read dispatch in the ScyllaInputAction wrapper, so set it
         * deliberately to avoid ReadFloat / ReadVector2 type-mismatch fallbacks. */
        var jumpUnityAction = new InputAction(
            name: "Jump",
            type: InputActionType.Button,
            binding: "<Keyboard>/space",
            expectedControlType: "Button"
        );

        /* Wrap and add. The ScyllaInputAction subscribes to Unity callbacks on
         * construction; the map subscribes to the ScyllaInputAction events on AddAction. */
        var jumpAction = new ScyllaInputAction(jumpUnityAction, mapID: "Test");
        map.AddAction(jumpAction);

        /* Register with the manager. Modifier-disambiguation metadata is recomputed
         * across every registered action after the new map joins, so the new bindings
         * participate in the same suppression rules as editor-authored ones. */
        actions.RegisterActionMap(map);

        /* Programmatic maps are not auto-enabled. Enable explicitly when the actions
         * should start receiving input. */
        map.Enable();

        return map;
    }
}

API Sketch

The grouping below mirrors how your code reaches into the action layer: manager facade, action wrapper, map wrapper, binding wrapper, then the event types published on the bus.

namespace Scylla.Input
{
    public interface IInputActionManager
    {
        bool IsInitialized       { get; }
        int  ActionMapCount      { get; }
        int  ActionCount         { get; }

        /* Registration. */
        void RegisterActionAsset  (UnityEngine.InputSystem.InputActionAsset asset);
        void UnregisterActionAsset(UnityEngine.InputSystem.InputActionAsset asset);
        void RegisterActionMap    (ScyllaInputActionMap map);
        void UnregisterActionMap  (string mapID);
        bool HasActionMap         (string mapID);

        /* Lookup. */
        ScyllaInputAction                       GetAction      (string actionID);
        bool                                    TryGetAction   (string actionID, out ScyllaInputAction action);
        ScyllaInputActionMap                    GetActionMap   (string mapID);
        IReadOnlyList<ScyllaInputAction>        GetAllActions  ();
        IReadOnlyList<ScyllaInputAction>        GetActionsInMap(string mapID);
        IReadOnlyList<ScyllaInputActionMap>     GetAllActionMaps();

        /* Map control. */
        void EnableActionMap     (string mapID);
        void DisableActionMap    (string mapID);
        bool IsActionMapEnabled  (string mapID);
        void EnableAllActionMaps ();
        void DisableAllActionMaps();

        /* Value and state queries by ID (the no-cache convenience overloads). */
        float   GetActionValue              (string actionID);
        Vector2 GetActionVector2            (string actionID);
        bool    IsActionPressed             (string actionID);
        bool    WasActionPerformedThisFrame (string actionID);
        bool    WasActionStartedThisFrame   (string actionID);
        bool    WasActionCanceledThisFrame  (string actionID);
    }

    public class ScyllaInputAction
    {
        public string  ActionID            { get; }
        public string  MapID               { get; }
        public string  DisplayName         { get; }
        public UnityEngine.InputSystem.InputActionType ActionType { get; }
        public System.Type ValueType       { get; }
        public string  ExpectedControlType { get; }
        public UnityEngine.InputSystem.InputAction UnityAction { get; }

        public bool    IsEnabled              { get; }
        public bool    IsPressed              { get; }
        public bool    WasStartedThisFrame    { get; }
        public bool    WasPerformedThisFrame  { get; }
        public bool    WasCanceledThisFrame   { get; }

        public IReadOnlyList<ScyllaInputBinding> Bindings { get; }
        public int     BindingCount           { get; }

        /* Most-recent activity tracking, updated on every Unity callback. */
        public UnityEngine.InputSystem.InputDevice  LastActiveDevice  { get; }
        public UnityEngine.InputSystem.InputControl LastActiveControl { get; }
        public double                                LastPerformedTime { get; }

        /* Modifier disambiguation metadata, computed at registration. */
        public int  ModifierDepth      { get; }
        public bool NeedsModifierCheck { get; }

        /* Value reads. */
        public float   ReadFloat   ();
        public Vector2 ReadVector2 ();
        public T       ReadValue<T>    () where T : struct;  /* throws on type mismatch */
        public T       ReadValueSafe<T>() where T : struct;  /* returns default on mismatch */

        /* Binding access. */
        public ScyllaInputBinding GetBinding          (int index);
        public string             GetBindingDisplayString(int bindingIndex = 0,
            InputControlPath.HumanReadableStringOptions options = InputControlPath.HumanReadableStringOptions.OmitDevice);
        public IReadOnlyList<ScyllaInputBinding> GetBindingsForGroup(string group);
        public void              RefreshBindings();

        /* Per-action enable / disable (use map-level enable for the common case). */
        public void Enable ();
        public void Disable();

        /* Event hooks at the wrapper level. Same payload as the bus events. */
        public event Action<ScyllaInputAction> OnPerformed;
        public event Action<ScyllaInputAction> OnStarted;
        public event Action<ScyllaInputAction> OnCanceled;
    }

    public class ScyllaInputActionMap
    {
        public string  MapID       { get; }
        public string  DisplayName { get; }
        public UnityEngine.InputSystem.InputActionMap UnityMap { get; }
        public bool    IsEnabled   { get; }
        public IReadOnlyList<ScyllaInputAction> Actions { get; }
        public int     ActionCount { get; }

        public ScyllaInputActionMap(UnityEngine.InputSystem.InputActionMap unityMap);
        public ScyllaInputActionMap(string mapID, string displayName = null);

        public void               AddAction   (ScyllaInputAction action);
        public bool               RemoveAction(string actionID);
        public ScyllaInputAction  GetAction   (string actionID);
        public bool               TryGetAction(string actionID, out ScyllaInputAction action);
        public bool               HasAction   (string actionID);

        public void Enable ();
        public void Disable();

        public event Action<ScyllaInputActionMap> OnEnabled;
        public event Action<ScyllaInputActionMap> OnDisabled;
        public event Action<ScyllaInputAction>    OnActionPerformed;
        public event Action<ScyllaInputAction>    OnActionStarted;
        public event Action<ScyllaInputAction>    OnActionCanceled;
    }

    public class ScyllaInputBinding
    {
        public Guid    BindingID         { get; }
        public int     BindingIndex      { get; }
        public string  ActionID          { get; }
        public string  Path              { get; }
        public string  EffectivePath     { get; }
        public string  DisplayString     { get; }
        public string  Name              { get; }
        public string  Groups            { get; }
        public bool    IsComposite       { get; }
        public bool    IsPartOfComposite { get; }
        public bool    HasOverride       { get; }
        public string  OverridePath      { get; }
        public UnityEngine.InputSystem.InputBinding UnityBinding { get; }

        public void            Refresh(UnityEngine.InputSystem.InputBinding unityBinding);
        public string          GetDisplayString(InputControlPath.HumanReadableStringOptions options = InputControlPath.HumanReadableStringOptions.OmitDevice);
        public bool            BelongsToGroup  (string group);
        public InputDeviceType GetDeviceType   ();
    }

    /* Event payloads published on the SEX bus. */
    public class InputActionPerformedEvent : Scylla.Core.Events.ScyllaEvent
    {
        public string  ActionID     { get; set; }
        public string  MapID        { get; set; }
        public float   Value        { get; set; }
        public Vector2 Vector2Value { get; set; }
        public UnityEngine.InputSystem.InputDevice Device { get; set; }
        public double  Time         { get; set; }
    }

    public class InputActionStartedEvent  : Scylla.Core.Events.ScyllaEvent { /* same shape */ }
    public class InputActionCanceledEvent : Scylla.Core.Events.ScyllaEvent { /* same shape */ }
}

See the API reference for full signatures, remarks, and the complete event roster.

Composite binding reference

The composites that interact with the modifier disambiguation rule, and what they mean for the action they belong to.

Composite typeAuthored asModifier depthTrigger control pathNotes
Simple (non-composite)Single binding to a control path0The binding’s EffectivePathThe default. One binding row in the asset editor. Participates in disambiguation only as a “bare” sibling of a modifier composite on the same trigger.
ButtonWithOneModifierComposite container + modifier + button1The button part’s EffectivePathFires only when the modifier is held and the button is pressed. Suppresses any bare-key sibling on the same trigger path during disambiguation.
ButtonWithTwoModifiersComposite container + modifier1 + modifier2 + button2The button part’s EffectivePathFires only when both modifiers and the button are held. Suppresses both bare-key and one-modifier siblings on the same trigger.
2DVector (WASD, IJKL)Composite container + up + down + left + right0 (per part)Each direction part contributes its own pathUsed for movement input. Does not participate in modifier disambiguation as a unit because each part is its own trigger; relevant for ReadVector2.
1DAxisComposite container + negative + positive0 (per part)Each part contributes its own pathTwo-key axis (e.g. Q/E for camera roll). Returns -1, 0, or +1 from ReadFloat.
OneModifierComposite container + modifier + binding1The binding part’s EffectivePathGeneric single-modifier composite for non-button actions (axes, vectors). Same disambiguation rules as ButtonWithOneModifier.

Best Practices

  • Cache ScyllaInputAction references in Start (inside RunWhenReady), not in Update. ActionManager.GetAction("Foo") is a dictionary lookup – cheap but not free; the cost adds up on a hot path. The reference stays valid until the owning asset is unregistered, so one lookup at startup is the right shape.
  • Use fully qualified "MapID/ActionID" lookup when two maps share an action name. Plain-name lookup picks the first match across all maps, which is order-dependent. Be explicit when the codebase grows past two maps.
  • Prefer WasPerformedThisFrame for one-shot transitions; reserve IsPressed for held-state queries. IsPressed returns true for every frame the binding is held; pairing it with a “did I already handle this” flag in user code duplicates work the action manager already does.
  • ReadFloat and ReadVector2 handle type mismatches gracefully; ReadValue<T> does not. Use the typed convenience methods for the common cases; reach for ReadValueSafe<T> when the action’s type is dynamic; use the unsafe ReadValue<T> only when the type match is known and the cost of the try/catch is unacceptable.
  • Subscribe to InputActionPerformedEvent once and dispatch on ActionID inside the handler. One handler that switches on the ID is faster and clearer than ten handlers subscribed to the same event type and filtering through is checks.
  • Author modifier-bound actions as ButtonWithOneModifier (or Two) composites, not as bare actions guarded by a modifier check. The composite approach lets the framework’s disambiguation engage automatically. Authoring Ctrl+Z as “an action bound to Z with a modifier check in the callback” defeats the suppression rule and reintroduces the double-fire bug.
  • Disable maps for context switches; disable individual actions for cooldowns. ActionManager.DisableActionMap("Gameplay") cleanly stops every gameplay action when a menu opens. See Contexts for the context-stack approach. action.Disable() is the right shape for a single ability that needs to be unavailable for a few seconds.
  • Always store the Listen return value. Discarding the subscription token makes the listener effectively permanent. Field-store the token, dispose in OnDestroy (for MonoBehaviours) or OnShutdown (for modules).
  • Cache GetBindingsForGroup results outside Update. The method filters by substring and allocates a list every call. For per-frame HUD code, cache the result on ControlSchemeChangedEvent instead.
  • Use action.UnityAction and binding.UnityBinding as escape hatches, not as the default. The wrappers cover the common cases; the underlying Unity objects exist for the rare cases that need specialised access (interaction processors, custom phase tracking). Reach for the wrapper-level API first.

Pitfalls