Contexts

28 min read

The input context stack that lets you switch between gameplay, menus, pause, and dialogs by push and pop without writing a single manual action-map toggle.

Summary

Every game has the same failure. A menu opens on top of gameplay and the player fires a weapon by pressing the confirm button. A dialog closes and movement does not come back on. A pause menu pauses correctly but kills the global screenshot hotkey. Those bugs all come from the same root: scattered code that manually toggles action maps on and off, with no single owner tracking which mode the game is currently in.

The context stack is that owner. You define each meaningful game mode (Gameplay, Menu, Pause, Dialog, Console) as a named InputContext that declares which action maps it wants enabled and which it wants disabled. Pushing a context applies that list as one atomic transition. Popping it applies the previous context’s list back. Your gameplay code stops asking “should I read input right now?” because IInputContextManager has already made sure the only maps that are active are the ones the current mode wants.

A context’s blocking behaviour controls how strictly it shadows the contexts below it on the stack. BlocksLowerPriority (the common case) prevents contexts with a strictly lower priority value from receiving input while the blocking context sits above them. ConsumesAllInput (the stronger case) blocks every context below regardless of priority – the right shape for a modal dialog or loading screen that must capture input exclusively. A Global context (priority 100, blocks nothing, holds always-available hotkeys like screenshot and quit) coexists with everything else precisely because no BlocksLowerPriority flag from a lower-priority context can shadow it.

The stack publishes three events through the SEX (Scylla Event eXchange) bus on every transition: InputContextPushedEvent, InputContextPoppedEvent, and InputContextChangedEvent (fired immediately after each push or pop as a summary). Your UI code subscribes to the change event to decide which button prompts to show; audio subscribes to react to mode transitions; cinematics subscribe to pause music when a dialog opens. Each context also exposes OnActivated and OnDeactivated C# events for code that already holds the InputContext reference and wants a narrower hook than the global bus.

Use this page for:

  • Switching between Gameplay, Menu, Pause, Dialog, and Console modes without manually enabling and disabling action maps in each gameplay system.
  • Pushing a pause context that blocks gameplay input but leaves a global screenshot hotkey alive.
  • Stacking a dialog context above a pause context above a gameplay context, and unwinding correctly when each closes.
  • Wiring UI code to context changes (button-prompt swaps, modal-open audio, save-on-pause flows).
  • Gating context-aware polling code with ShouldProcessInput so a polled action read in Update honours the same blocking rules the event-driven path already does.
  • Authoring contexts from the Inspector via InputContextDefinition data assets, or directly in code via the fluent InputContext builder.

Features

  • LIFO context stack. PushContext adds to the top, PopContext removes from the top. The top context is the active one; any context not blocked by a higher entry still processes input. ActiveContext and ContextStack are read-only snapshots.
  • Two-tier blocking model. BlocksLowerPriority blocks lower-priority contexts while the blocking one is above them. ConsumesAllInput blocks everything below regardless of priority. The standard pattern is gameplay at priority 0 (blocks nothing), menu and pause at 80-90 (blocks lower), global at 100 (blocks nothing so it stays alive).
  • Action map enable/disable lists per context. Each InputContext carries EnabledActionMaps and DisabledActionMaps lists. On activation, disabled maps go down first, then enabled maps come up – the order is deliberate so a context can replace another’s bindings atomically without a one-frame gap where nothing is active.
  • Fluent context construction. new InputContext("Menu", priority: 80).EnableActionMap("Menu").DisableActionMap("Gameplay").SetBlocksLowerPriority(true) reads as one sentence. EnableActionMaps(params string[]) and DisableActionMaps(params string[]) handle bulk additions.
  • Stack queries. IsContextActive (is it on the stack at all), IsContextBlocked (is it shadowed by a higher context’s blocking flag), ShouldProcessInput (both at once, the gate for polling), GetContextStackPosition (where in the stack), StackDepth (how many entries).
  • Two event surfaces. Bus-wide via InputContextPushedEvent, InputContextPoppedEvent, and InputContextChangedEvent for subscribers that do not hold a specific context reference; per-context via InputContext.OnActivated and InputContext.OnDeactivated for code that owns a context and wants the narrower hook.
  • Exclusion groups for mutually-exclusive contexts. Setting the same ExclusionGroup string on two contexts marks them as never-active-at-the-same-time; the conflict detection in the Rebinding system uses this to skip false-positive binding conflicts between maps the player will never see together.
  • Inspector-authored context definitions. InputContextDefinition is a serialised data object that lives in a ScyllaInputContextConfiguration asset; calling CreateContext() on the definition emits a runtime InputContext with the same configuration. The framework’s default contexts (Gameplay, Menu, Console, Global) ship as definitions in the default configuration.

Concepts

The vocabulary used throughout this page. Each row is a type or a term that recurs in the code examples below.

ConceptType / shapeNotes
InputContextsealed class with fluent builderA named mode of the game. Has an immutable ContextID, Priority, DisplayName, plus mutable EnabledActionMaps, DisabledActionMaps, BlocksLowerPriority, ConsumesAllInput, ExclusionGroup. Reachable via OnActivated and OnDeactivated per-context callbacks.
InputContextDefinitionserialisable data objectInspector-authored shape of a context, stored in ScyllaInputContextConfiguration assets. Converted to a runtime InputContext via CreateContext(). Use this when you want contexts driven by data rather than code.
IInputContextManagerinterface, accessed via ScyllaInput.Instance.ContextManagerThe stack owner. Exposes RegisterContext, PushContext, PopContext, PopToContext, RemoveContext, ClearContextStack, plus the queries below.
LIFO stacksemantic model, not a typePush adds to the top; pop removes from the top. The top is the active context. Lower entries may still process input if no higher entry’s blocking flag shadows them.
BlocksLowerPriorityper-context boolWhen the context is above another with a strictly lower Priority, the lower context returns true from IsContextBlocked. Does not block contexts with equal or higher priority.
ConsumesAllInputper-context boolStronger form: blocks every context below the consumer regardless of priority. Right shape for modal dialogs and loading screens.
ShouldProcessInput(contextID)manager queryEquivalent to IsContextActive(id) && !IsContextBlocked(id). The primary gate for polling code that should respect the same blocking rules the event-driven path already honours.
InputContextChangedEvent and friendsScyllaEvent subtypes published on the SEX busFired on every stack transition. Changed carries previous and current context; Pushed and Popped carry the affected context and the resulting StackDepth.

Recipes

These recipes cover the full context stack surface. Each one is self-contained, so jump to the one that matches what you are building. If you are new to the API, start with the first recipe – it sets up the four-context stack that the other recipes assume.

Set up the context stack at game startup

Problem. You need a set of named contexts (Gameplay, Pause, Dialog, Global) ready before any gameplay code runs, so that later push and pop calls can reference them by ID without auto-registering on the fly. You also want to understand the relationship between registration and pushing, because mixing them up is the source of “the context opens in the test scene but not the shipped build” bugs.

Solution. Build each context with the fluent API, register it once at startup, then push the initial stack. Registration makes the context known by ID; pushing makes it active. The two steps are intentionally separate so you can wire the full registry on one frame and then push individual contexts as the game state demands.

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

public sealed class InputContextBootstrap : MonoBehaviour
{
    private void Start()
    {
        /* RunWhenReady gates the first ContextManager call until the framework
         * is fully initialized. Without this gate, ScyllaInput.Instance is null
         * on the first frame and the calls below throw NullReferenceException. */
        ScyllaCore.Instance.RunWhenReady(BuildAndRegisterContexts);
    }

    private void BuildAndRegisterContexts()
    {
        /* Gameplay: lowest priority, no blocking. The base context every
         * other one stacks on top of. Enables the two gameplay action maps;
         * any others stay in their current state. */
        var gameplay = new InputContext("Gameplay", "Gameplay Controls", priority: 0)
            .EnableActionMaps("Player", "Vehicle")
            .SetBlocksLowerPriority(false);

        /* Pause: priority 90, blocks lower-priority contexts. While Pause is
         * on the stack, Gameplay (priority 0) is blocked and its action maps
         * are disabled. Global (priority 100) is NOT blocked because its
         * priority is equal or higher - that is the property that keeps the
         * screenshot hotkey alive during pause. */
        var pause = new InputContext("Pause", "Pause Menu", priority: 90)
            .EnableActionMap("PauseMenu")
            .DisableActionMap("Player")
            .DisableActionMap("Vehicle")
            .SetBlocksLowerPriority(true);

        /* Dialog: priority 100, ConsumesAllInput. While Dialog is on the
         * stack, every other context below it is blocked, including Global,
         * because ConsumesAllInput overrides priority comparison. Right shape
         * for a modal sequence that must capture every input. */
        var dialog = new InputContext("Dialog", "Dialog", priority: 100)
            .EnableActionMap("Dialog")
            .SetConsumesAllInput(true);

        /* Global: priority 100, blocks nothing. Lives at the bottom of the
         * stack for the lifetime of the game and holds always-on hotkeys
         * (screenshot, quit-to-menu, debug-console-toggle). Only a context
         * with ConsumesAllInput can shadow it. */
        var global = new InputContext("Global", "Global Hotkeys", priority: 100)
            .EnableActionMap("Global")
            .SetBlocksLowerPriority(false);

        /* RegisterContext is exposed both on the facade and on the manager;
         * both forms are equivalent. Register all four before pushing any. */
        ScyllaInput.Instance.RegisterContext(gameplay);
        ScyllaInput.Instance.RegisterContext(pause);
        ScyllaInput.Instance.RegisterContext(dialog);
        ScyllaInput.Instance.RegisterContext(global);

        /* Initial stack: Global at the bottom (always alive), Gameplay on
         * top (the default mode). Push order matters: bottom first, top last.
         * PushContext(string) requires the context to be registered first;
         * the InputContext overload auto-registers if needed. */
        ScyllaInput.Instance.PushContext("Global");
        ScyllaInput.Instance.PushContext("Gameplay");
    }
}

Push the pause menu and block gameplay input

Problem. You are building a pause screen. When it opens, the player should not be able to fire a weapon, move a character, or interact with the world behind the menu – but they should still be able to take a screenshot via the always-on Global hotkey. When the pause screen closes, gameplay should resume exactly where it left off.

Solution. Push the Pause context when the pause button is pressed. Its BlocksLowerPriority flag and DisableActionMaps list handle everything. The Global context stays alive because its priority is not strictly lower than Pause’s. Pop it to return to gameplay.

using Scylla.Input;
using UnityEngine;

public sealed class PauseMenuController : MonoBehaviour
{
    private void Update()
    {
        /* You would typically wire this to an action callback rather than
         * polling. Shown here as polling for simplicity. */
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            TogglePause();
        }
    }

    private void TogglePause()
    {
        var manager = ScyllaInput.Instance.ContextManager;

        if (manager.IsContextActive("Pause"))
        {
            /* Pop Pause to return to Gameplay. The manager re-applies
             * Gameplay's EnabledActionMaps list so the Player and Vehicle
             * maps come back on atomically. */
            ScyllaInput.Instance.PopContext();
            HidePauseMenu();
        }
        else
        {
            /* Push Pause. The Pause context's DisableActionMaps list turns
             * off Player and Vehicle; its EnableActionMap turns on PauseMenu.
             * Global (priority 100) is NOT blocked by Pause (priority 90)
             * because 100 >= 90, so the screenshot hotkey stays live. */
            ScyllaInput.Instance.PushContext("Pause");
            ShowPauseMenu();
        }
    }

    private void ShowPauseMenu() { /* ... */ }
    private void HidePauseMenu() { /* ... */ }
}

Open a dialog that blocks everything including Global

Problem. You have a critical story dialog – the moment where the player chooses a faction or confirms an irreversible action. You need all input silenced during this sequence: no weapon fires from behind the dialog, no pause menu opening mid-speech, and no screenshot hotkey interrupting the cutscene. The dialog must be the only thing that receives input.

Solution. Set ConsumesAllInput on the dialog context. That flag supersedes priority comparison entirely, so even Global (priority 100) is blocked while the dialog sits on the stack. Anything you want the player to be able to do during the dialog belongs in the dialog’s own action map.

using Scylla.Input;

public sealed class StoryDialogSystem
{
    private readonly InputContext _dialogContext;

    public StoryDialogSystem()
    {
        /* ConsumesAllInput = true means this context blocks every context
         * below it on the stack, including Global, regardless of priority.
         * This is exactly what you want for a modal story sequence. */
        _dialogContext = new InputContext("Dialog", "Story Dialog", priority: 100)
            .EnableActionMap("Dialog")
            .SetConsumesAllInput(true);

        ScyllaInput.Instance.RegisterContext(_dialogContext);

        /* Per-context events. Cleaner than filtering the global bus event
         * on ContextID == "Dialog" when this class already owns the context. */
        _dialogContext.OnActivated   += OnDialogActivated;
        _dialogContext.OnDeactivated += OnDialogDeactivated;
    }

    public void OpenDialog()
    {
        /* Push Dialog. Every context below is now blocked, including Global.
         * If the player must be able to hit a force-quit hotkey during this
         * dialog, add that action to the "Dialog" action map rather than
         * relying on Global. */
        ScyllaInput.Instance.PushContext("Dialog");
        ShowDialogUI();
    }

    public void CloseDialog()
    {
        ScyllaInput.Instance.PopContext();
        HideDialogUI();
    }

    private void OnDialogActivated(InputContext ctx)   { /* lower music, pause ambient SFX */ }
    private void OnDialogDeactivated(InputContext ctx) { /* restore music */ }

    private void ShowDialogUI() { /* ... */ }
    private void HideDialogUI() { /* ... */ }
}

Layer a temporary vehicle context

Problem. Your on-foot platformer has vehicles the player can enter. When the player is in a vehicle, you want completely different action bindings (throttle, steer, honk) and you want the on-foot bindings to disappear so the same button cannot trigger both a jump and an acceleration at the same time. When the player exits, on-foot controls should come straight back.

Solution. Push a Vehicle context on top of Gameplay when the player enters, pop it when they exit. The Vehicle context’s DisableActionMaps list turns off the on-foot map; its EnableActionMap turns on the vehicle map. Popping restores the previous state.

using Scylla.Input;

public sealed class VehicleController
{
    private static InputContext _vehicleContext;

    /* Call once at startup to register the Vehicle context. */
    public static void RegisterVehicleContext()
    {
        /* Vehicle sits at the same priority as Gameplay (0). It does not
         * need to block anything - it simply replaces the active action maps
         * by naming exactly what it wants enabled and disabled. */
        _vehicleContext = new InputContext("Vehicle", "Vehicle Controls", priority: 0)
            .EnableActionMap("Vehicle")
            .DisableActionMap("Player")
            .SetBlocksLowerPriority(false);

        ScyllaInput.Instance.RegisterContext(_vehicleContext);
    }

    public void OnPlayerEnterVehicle()
    {
        /* Pushing Vehicle turns off Player and turns on Vehicle atomically.
         * Gameplay is still on the stack below, so popping Vehicle later
         * restores the Gameplay action maps without any extra code. */
        ScyllaInput.Instance.PushContext("Vehicle");
    }

    public void OnPlayerExitVehicle()
    {
        /* Pop Vehicle. The manager re-applies the context below (Gameplay),
         * re-enabling the Player map. The player can move immediately. */
        ScyllaInput.Instance.PopContext();
    }
}

Understand how the two blocking modes work side by side

Problem. You have set up a stack with Gameplay, Pause, and Global contexts and the behavior is not what you expected: Global stays alive during Pause (which is correct) but becomes silent during a Dialog (also correct). You want a clear mental model of what BlocksLowerPriority and ConsumesAllInput actually do, especially in the edge case where two contexts share the same priority.

Solution. The key insight is that BlocksLowerPriority compares priority values, not stack positions. A context at priority 100 with BlocksLowerPriority = true blocks priority 99 but does not block priority 100. ConsumesAllInput ignores priority entirely and blocks everything. The text diagram and the ShouldProcessInput table below show the full picture.

Stack state (top to bottom), showing which contexts are active:

  Scenario A: gameplay only
    [active] Gameplay (priority 0, no blocking)
             Global   (priority 100, no blocking)
    -> Both contexts process input. Player/Vehicle maps are on;
       Global map is on. The player can move and take screenshots
       at the same time, which is what you want.

  Scenario B: pause menu opens
    [active] Pause    (priority 90, BlocksLowerPriority = true)
             Gameplay (priority 0, blocked: 0 < 90)
             Global   (priority 100, NOT blocked: 100 >= 90)
    -> Pause processes input. Gameplay is blocked (its maps were
       also disabled by Pause's DisableActionMap list on push).
       Global keeps processing because its priority is not strictly
       lower than Pause's; the screenshot hotkey still works.

  Scenario C: modal dialog opens during pause
    [active] Dialog   (priority 100, ConsumesAllInput = true)
             Pause    (priority 90,  blocked: ConsumesAllInput overrides)
             Gameplay (priority 0,   blocked: same)
             Global   (priority 100, blocked: same)
    -> Only Dialog processes input. Even Global is blocked because
       ConsumesAllInput supersedes priority comparison. The player
       cannot take a screenshot, cannot interact with the pause menu
       behind the dialog, cannot move.

The same scenarios as ShouldProcessInput query results:

Stack scenarioShouldProcessInput("Gameplay")ShouldProcessInput("Pause")ShouldProcessInput("Dialog")ShouldProcessInput("Global")
A: Gameplay + Globaltruefalse (not on stack)false (not on stack)true
B: Pause + Gameplay + Globalfalse (blocked)truefalse (not on stack)true
C: Dialog + Pause + Gameplay + Globalfalsefalsetruefalse (blocked by ConsumesAllInput)

Unwind nested menus and jump back to gameplay

Problem. Your game has nested menus: pause menu, then settings submenu, then a confirmation dialog on top of that. The player can press a “Return to Game” button from any level. You want to pop the entire chain in one call rather than counting how many pops are needed.

Solution. PopToContext repeatedly calls PopContext until the named context is at the top, firing the standard events for every intermediate context. For side-channel cancellations (a network event that forces a dialog closed regardless of what is layered above it), RemoveContext pulls a specific context out of any stack position.

using Scylla.Input;

public sealed class MenuFlow
{
    /* The most common case: pop the top context on a back button press. */
    public void CloseTopMenu()
    {
        /* PopContext returns the removed InputContext (or null if the stack
         * was empty). The manager has already invoked OnDeactivated and
         * published InputContextPoppedEvent + InputContextChangedEvent. */
        var popped = ScyllaInput.Instance.PopContext();
        if (popped != null && popped.ContextID == "Dialog")
        {
            /* Resume background music that was paused on dialog open. */
        }
    }

    /* Unwind a whole chain in one call. */
    public void ReturnToGameplay()
    {
        /* PopToContext pops repeatedly until "Gameplay" is at the top,
         * firing the standard events for every intermediate context.
         * Returns the number of contexts popped. Zero is returned if
         * Gameplay is already at the top or is not on the stack at all
         * (no contexts are popped in the not-on-stack case, so calling
         * this defensively is safe). */
        var manager = ScyllaInput.Instance.ContextManager;
        var count   = manager.PopToContext("Gameplay");
        UnityEngine.Debug.Log($"Popped {count} contexts to return to Gameplay.");
    }

    /* Remove a context from any stack position without affecting others. */
    public void ForceCloseDialog()
    {
        /* A network event forces the dialog closed regardless of what is
         * layered on top. RemoveContext handles any stack position. */
        ScyllaInput.Instance.ContextManager.RemoveContext("Dialog");
    }

    /* Clear everything before a scene transition. */
    public void TearDownStack()
    {
        /* Every pop fires its standard events. After this call, push your
         * new scene's context stack from scratch. Carrying a stack across
         * scenes produces "the dialog from level 1 is still active in
         * level 5" bugs. */
        ScyllaInput.Instance.ContextManager.ClearContextStack();
    }
}

Subscribe to context changes in the HUD

Problem. Your HUD controller needs to swap button prompts every time the active context changes. When the game is in Gameplay mode you show movement and action prompts; when it switches to Menu you swap to menu-navigation prompts. You want a single subscription that handles both directions without two separate handler bodies.

Solution. Subscribe to InputContextChangedEvent on the SEX bus. It fires immediately after every push or pop and carries both the previous and the current context ID in one payload. Use the per-context OnActivated and OnDeactivated events when a system already owns a specific context reference and only cares about that one context.

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

public sealed class HUDController : MonoBehaviour
{
    /* Store the subscription token for cleanup on destroy. */
    private ScyllaEventSubscription _changedSubscription;

    private void Start()
    {
        Scylla.Core.ScyllaCore.Instance.RunWhenReady(() =>
        {
            /* Subscribe to the changed event. One subscription handles both
             * directions (open and close) without combining two handlers. */
            _changedSubscription = ScyllaEvents.Listen<InputContextChangedEvent>(
                subscriber: this,
                handler:    OnContextChanged,
                priority:   ScyllaEventPriority.Medium
            );
        });
    }

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

    private void OnContextChanged(InputContextChangedEvent evt)
    {
        /* PreviousContextID and CurrentContextID are both strings. Each may
         * be null when the stack was or is empty. InputContext references are
         * also available on the event for priority, action map lists, etc. */
        if (evt.CurrentContextID == "Gameplay")
        {
            ShowGameplayHUD();
        }
        else
        {
            HideGameplayHUD();
        }
    }

    private void ShowGameplayHUD() { /* swap to movement + action prompts */ }
    private void HideGameplayHUD() { /* hide gameplay prompts */ }
}

/* Per-context events when the listener already owns the context. */
public sealed class DialogAudioReactor
{
    private readonly InputContext _dialogContext;

    public DialogAudioReactor(InputContext dialogContext)
    {
        _dialogContext = dialogContext;

        /* Cleaner than filtering the bus event on ContextID == "Dialog"
         * when this class already holds the context reference. Unsubscribe
         * with -= when no longer needed. */
        _dialogContext.OnActivated   += HandleDialogActivated;
        _dialogContext.OnDeactivated += HandleDialogDeactivated;
    }

    private void HandleDialogActivated(InputContext ctx)   { /* lower music volume */ }
    private void HandleDialogDeactivated(InputContext ctx) { /* restore music volume */ }
}

Gate polling code with ShouldProcessInput

Problem. Your character controller reads _jumpAction.WasPerformedThisFrame in Update. When the pause menu is open, the jump action’s map is disabled by the Pause context, but there is a one-frame window on the transition where the underlying Unity action has not fully settled. You want a reliable gate that honours the context model even during that window, and for any other polled action that might share a map across multiple contexts.

Solution. Gate the polled read on ShouldProcessInput("Gameplay"). It is equivalent to IsContextActive && !IsContextBlocked, so it returns false the moment the Pause context lands on the stack, well before any map-disable settlement.

using Scylla.Input;
using UnityEngine;

public sealed class GameplayPoller : MonoBehaviour
{
    private ScyllaInputAction _jumpAction;
    private IInputContextManager _contexts;

    private void Start()
    {
        Scylla.Core.ScyllaCore.Instance.RunWhenReady(() =>
        {
            _jumpAction = ScyllaInput.Instance.ActionManager.GetAction("Jump");
            _contexts   = ScyllaInput.Instance.ContextManager;
        });
    }

    private void Update()
    {
        if (_jumpAction == null || _contexts == null) return;

        /* Gate the polled read on the gameplay context being unblocked.
         * When Pause is pushed above Gameplay, ShouldProcessInput returns
         * false and the jump is ignored even if the underlying action map
         * has not fully toggled off yet. */
        if (!_contexts.ShouldProcessInput("Gameplay")) return;

        if (_jumpAction.WasPerformedThisFrame)
        {
            Jump();
        }
    }

    private void Jump() { /* apply jump velocity */ }
}

API Sketch

The manager surface, the InputContext builder surface, and the three event payloads. The manager is reached through ScyllaInput.Instance.ContextManager; the convenience facade methods on ScyllaInput.Instance (RegisterContext, PushContext, PopContext, UnregisterContext) forward to the manager.

namespace Scylla.Input
{
    public interface IInputContextManager
    {
        /* Stack snapshot. */
        InputContext               ActiveContext  { get; }
        IReadOnlyList<InputContext> ContextStack  { get; }
        int                        StackDepth     { get; }
        bool                       IsInitialized  { get; }

        /* Per-context activation events (SEX bus events also published; see below). */
        event Action<InputContext, InputContext> OnContextChanged; /* (previous, current) */
        event Action<InputContext> OnContextPushed;
        event Action<InputContext> OnContextPopped;

        /* Registry. */
        bool                       RegisterContext  (InputContext context);
        bool                       UnregisterContext(string contextID);
        bool                       HasContext       (string contextID);
        InputContext               GetContext       (string contextID);
        IReadOnlyList<InputContext> GetAllContexts  ();

        /* Stack operations. */
        bool                       PushContext     (string contextID);
        bool                       PushContext     (InputContext context); /* auto-registers if new */
        InputContext               PopContext      ();
        int                        PopToContext    (string contextID);
        bool                       RemoveContext   (string contextID);
        void                       ClearContextStack();

        /* Queries. */
        bool IsContextActive       (string contextID); /* on the stack at any position */
        bool IsContextBlocked      (string contextID); /* shadowed by a higher blocker */
        int  GetContextStackPosition(string contextID); /* 0 = top, -1 = not on stack */
        bool ShouldProcessInput    (string contextID); /* IsContextActive && !IsContextBlocked */
    }

    public class InputContext
    {
        public InputContext(string contextID, string displayName = null,
            int priority = 0, string description = null);

        /* Identity (immutable). */
        public string ContextID        { get; }
        public string DisplayName      { get; }
        public string Description      { get; }
        public int    Priority         { get; }
        public string ExclusionGroup   { get; }

        /* Blocking flags (mutable; fluent setters below). */
        public bool   BlocksLowerPriority { get; set; }
        public bool   ConsumesAllInput    { get; set; }

        /* Action map lists. */
        public IReadOnlyList<string> EnabledActionMaps  { get; }
        public IReadOnlyList<string> DisabledActionMaps { get; }

        /* Stack-managed state (read-only externally). */
        public bool IsActive     { get; }   /* on the stack at any position */
        public bool IsTopContext { get; }   /* at the top of the stack */

        /* Per-context activation events. */
        public event Action<InputContext> OnActivated;
        public event Action<InputContext> OnDeactivated;

        /* Fluent configuration (return this for chaining). */
        public InputContext EnableActionMap (string mapID);
        public InputContext EnableActionMaps(params string[] mapIDs);
        public InputContext DisableActionMap (string mapID);
        public InputContext DisableActionMaps(params string[] mapIDs);
        public InputContext ClearActionMaps  ();
        public InputContext SetBlocksLowerPriority(bool blocks);
        public InputContext SetConsumesAllInput   (bool consumes);
    }

    /* Inspector-authored alternative; emits a runtime InputContext via CreateContext(). */
    public class InputContextDefinition
    {
        public string ContextID         { get; }
        public string DisplayName       { get; }
        public int    Priority          { get; }
        public bool   BlocksLowerPriority { get; }
        public bool   ConsumesAllInput  { get; }
        public string ExclusionGroup    { get; }
        public List<string> EnabledActionMaps  { get; }
        public List<string> DisabledActionMaps { get; }

        public InputContext CreateContext();
    }

    /* Convenience methods on the module facade (forward to the manager). */
    public sealed class ScyllaInput : ScyllaModule
    {
        public bool          RegisterContext   (InputContext context);
        public bool          UnregisterContext (string contextID);
        public bool          PushContext       (string contextID);
        public bool          PushContext       (InputContext context);
        public InputContext  PopContext        ();
        public InputContext  ActiveContext        { get; }
        public int           ContextStackDepth    { get; }
    }

    /* Event payloads published on the SEX (Scylla Event eXchange) bus. */
    public class InputContextChangedEvent : Scylla.Core.Events.ScyllaEvent
    {
        public string       PreviousContextID { get; }
        public string       CurrentContextID  { get; }
        public InputContext PreviousContext   { get; }
        public InputContext CurrentContext    { get; }
    }

    public class InputContextPushedEvent : Scylla.Core.Events.ScyllaEvent
    {
        public string       ContextID  { get; }
        public InputContext Context    { get; }
        public int          StackDepth { get; }
    }

    public class InputContextPoppedEvent : Scylla.Core.Events.ScyllaEvent
    {
        public string       ContextID  { get; }
        public InputContext Context    { get; }
        public int          StackDepth { get; }
    }
}

See the API reference for full signatures, remarks, and the complete sub-manager surface.

Default contexts shipped with the framework

A fresh ScyllaInputContextConfiguration asset is pre-populated with these four contexts. You can edit, remove, or extend them in the Inspector; “Reset to Defaults” restores them.

Context IDPriorityBlocks LowerConsumes AllEnabled Action MapsNotes
Gameplay0NoNoGameplay Common, Gameplay On FootBase context. Every other context stacks on top.
Menu80YesNoMenuBlocks gameplay input. Does not block Global (Global is priority 100, equal or higher, so not strictly lower).
Console90YesNoConsoleSame shape as Menu but higher priority. Blocks both Gameplay and Menu when active above them.
Global100NoNoGlobalHolds always-on hotkeys (screenshot, debug toggle, quit). Sits at the bottom of the stack for the game’s lifetime. Only a ConsumesAllInput context above it can shadow it.

Best Practices

  • Set Global at the maximum priority and push it first. The combination of “highest priority + no blocking + pushed at the bottom” is what keeps Global alive through every menu and dialog above it. Reserve ConsumesAllInput for the rare modal cases that must shadow even Global.
  • Use BlocksLowerPriority for menus and pauses; reserve ConsumesAllInput for modal dialogs and loading screens. The two flags exist because the priority-comparison rule does the right thing most of the time, and the “block everyone below regardless” rule is occasionally what you want. Picking the right one keeps Global hotkeys alive when they should be, and silent when they should be.
  • Author the four default contexts (Gameplay, Menu, Console, Global) as the starting set. A new project that adopts those names and priorities inherits behaviours that match the framework’s defaults and avoids invented-here-different-from-the-docs surprises.
  • Use DisableActionMaps deliberately, not by reflex. A context’s DisableActionMaps list shapes which Unity maps are off while the context is on top. Listing every map the game has just to “be safe” undoes the gameplay context’s EnableActionMaps work when the safety context is pushed; the maps that come back on pop may not be the ones the gameplay context had on. Disable only the maps that should be silent in the new context.
  • Subscribe to InputContextChangedEvent for HUD swaps, not to Pushed + Popped separately. The Changed event is published after every transition and carries previous and current in one payload, which fits the common “what should the HUD look like now” question. The Pushed and Popped events are useful when the subscriber genuinely cares which direction the transition went.
  • Use per-context OnActivated and OnDeactivated for code that already holds the context reference. When a system creates and owns a context (a dialog system that constructs its own Dialog context), the C# event on the context is a cleaner hook than the global bus event filtered on ContextID == "Dialog".
  • Gate polled action reads with ShouldProcessInput when the action’s map lives in more than one context. Most polled reads do not need the gate (the Unity map is disabled by the context, so the action stops firing). When the same map is enabled across multiple contexts, or when the polled read should respect the context model beyond the action manager’s reach, the gate is the right shape.
  • Match ExclusionGroup on contexts that the game design declares mutually exclusive. The conflict detection in the Rebinding system uses the exclusion group to skip false-positive conflicts; without it, a player rebinding Cancel in the Menu context will see a phantom conflict against Cancel in the Gameplay context even though those two will never be active at the same time.
  • Treat RegisterContext and PushContext as separate phases. Registration is the one-time “the manager learns this context exists by ID”; pushing is the per-frame “make this context active”. Building the registry once at startup and then using PushContext(string) for the rest of the game’s lifetime is faster and cleaner than pushing InputContext references each time.
  • Call ClearContextStack on scene unload, then re-register and re-push in the new scene’s bootstrap. Carrying a stack across scenes is a reliable way to end up with a dialog from level 1 still on the stack in level 5. The scene-unload and scene-load round trip is the natural boundary for the context registry.

Pitfalls