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
An input context is a named game mode (Gameplay, Menu, Pause, Dialog, Console) paired with the action maps that mode wants enabled and the ones it wants disabled. Each is an InputContext, and IInputContextManager keeps them on a stack. Pushing a context applies its two lists as one atomic transition, and popping it applies the previous context’s lists back. Gameplay code never toggles a map by hand and never has to ask whether it should be reading input right now, because the only maps active at any moment are the ones the current mode asked for.
Centralizing that decision heads off a familiar class of bug: the confirm button firing a weapon through an open menu, movement that never comes back after a dialog closes, a pause menu that also kills the global screenshot hotkey. Each of those traces back to scattered code toggling action maps on and off, with nothing tracking which mode the game is actually in.
A context’s blocking behavior 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 95, 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). 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
ShouldProcessInputso a polled action read inUpdatehonors the same blocking rules the event-driven path already does. - Authoring contexts from the Inspector via
InputContextDefinitiondata assets, or directly in code via the fluentInputContextbuilder.
Features
- LIFO context stack.
PushContextadds to the top,PopContextremoves from the top. The top context is the active one; any context not blocked by a higher entry still processes input.ActiveContextandContextStackare read-only snapshots. - Quiet by default. Pushing, popping, removing and clearing contexts each write one line at Debug level under the Input log category, so routine mode changes stay out of the console unless the logger is set to show Debug output.
- Two-tier blocking model.
BlocksLowerPriorityblocks lower-priority contexts while the blocking one is above them.ConsumesAllInputblocks everything below regardless of priority. The standard pattern is gameplay at priority 0 (blocks nothing), menu and pause at 80-90 (blocks lower), and global at 95 (blocks nothing so it stays alive). Priority 100 belongs to the Rebinding context, which consumes all input so the key the player presses reaches the rebind operation and nothing else. - Action map enable/disable lists per context. Each
InputContextcarriesEnabledActionMapsandDisabledActionMapslists. 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[])andDisableActionMaps(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, andInputContextChangedEventfor subscribers that do not hold a specific context reference; per-context viaInputContext.OnActivatedandInputContext.OnDeactivatedfor code that owns a context and wants the narrower hook. - Conflict-exclusive tags for mutually-exclusive contexts. Give two contexts a tag in common and they are marked as never active at the same time, so the conflict detection in the Rebinding system stops reporting the controls they share. A context can carry several tags when it belongs to more than one exclusive set. Tags are independent of priority and the blocking flags: they decide what a controls screen tells the player, not what the module disables at runtime.
- Inspector-authored context definitions.
InputContextDefinitionis a serialized data object that lives in aScyllaInputContextConfigurationasset; callingCreateContext()on the definition emits a runtimeInputContextwith the same configuration. The framework’s five default contexts (Gameplay, Menu, Console, Global, Rebinding) ship as definitions in the default configuration.
Concepts
The table below defines the vocabulary used throughout this page. Each row is a type or a term that recurs in the code examples that follow.
| Concept | Type / shape | Notes |
|---|---|---|
InputContext | class with fluent builder | A named mode of the game. Has an immutable ContextID, Priority, DisplayName, plus mutable EnabledActionMaps, DisabledActionMaps, BlocksLowerPriority, ConsumesAllInput, ConflictExclusiveTags. Reachable via OnActivated and OnDeactivated per-context callbacks. |
InputContextDefinition | serializable data object | Inspector-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. |
IInputContextManager | interface, accessed via ScyllaInput.Instance.ContextManager | The stack owner. Exposes RegisterContext, PushContext, PopContext, PopToContext, RemoveContext, ClearContextStack, plus the queries below. |
| LIFO stack | semantic model, not a type | Push 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. |
BlocksLowerPriority | per-context bool | When 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. |
ConsumesAllInput | per-context bool | Stronger form: blocks every context below the consumer regardless of priority. Right shape for modal dialogs and loading screens. |
ConflictExclusiveTags | per-context list of strings | Free-text tags marking mutual exclusivity. Contexts sharing at least one tag are understood never to be active together. Read only by conflict detection, never by the blocking model. Authored per context in the ScyllaInputContextConfiguration asset. |
InputContextExclusivity | static helper | The rule both conflict systems consult. AreContextsExclusive(a, b) is tag membership; AreMapsExclusive(contexts, mapA, mapB) crosses from tags on contexts to the action maps a conflict is actually about. |
ShouldProcessInput(contextID) | manager query | Equivalent to IsContextActive(id) && !IsContextBlocked(id). The primary gate for polling code that should respect the same blocking rules the event-driven path already honors. |
InputContextChangedEvent and friends | ScyllaEvent subtypes published on the SEX bus | Fired on every stack transition. Changed carries previous and current context; Pushed and Popped carry the affected context and the resulting StackDepth. |
Recipes
Each recipe below stands on its own and covers a different corner of the context stack, so pick whichever matches what you are building. Readers new to the API should still work through the first recipe, since it builds the four-context stack every later recipe assumes is already in place.
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 95) 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 95, 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: 95)
.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;
using UnityEngine.InputSystem;
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. */
var input = ScyllaInput.Instance;
if (input != null && input.WasKeyPressedThisFrame(Key.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 95) is NOT blocked by Pause (priority 90)
* because 95 >= 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 95) 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 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 95, 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 the intended behavior.
Scenario B: pause menu opens
[active] Pause (priority 90, BlocksLowerPriority = true)
Gameplay (priority 0, blocked: 0 < 90)
Global (priority 95, NOT blocked: 95 >= 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 95, 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 scenario | ShouldProcessInput("Gameplay") | ShouldProcessInput("Pause") | ShouldProcessInput("Dialog") | ShouldProcessInput("Global") |
|---|---|---|---|---|
| A: Gameplay + Global | true | false (not on stack) | false (not on stack) | true |
| B: Pause + Gameplay + Global | false (blocked) | true | false (not on stack) | true |
| C: Dialog + Pause + Gameplay + Global | false | false | true | false (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();
}
}
Stop a controls screen reporting conflicts between modes that never coexist
Problem. The game binds Space to Jump on foot and to Handbrake while driving, and the arrow keys drive both movement and menu navigation. All of that is deliberate: the player is never on foot and in a car at once, and the menu turns the gameplay maps off. The in-game controls screen does not know that, so it opens with a wall of red conflict markers against collisions the player can never trigger.
Solution. Give the contexts that are alternatives to one another a tag in common. Two contexts sharing at least one ConflictExclusiveTags entry are treated as never active together, and conflict detection stops reporting the controls they share. The tags are authored per context in the ScyllaInputContextConfiguration asset, so this is Inspector work rather than code.
Inspector steps:
1. Select the context configuration asset. Pick a context in the list.
2. In the detail panel below the list, find "Conflict-exclusive tags".
Press "+ Add" and type a name, for example "Modes".
3. Select the next context. Instead of typing the tag again, choose it
from the "(Add existing tag)" dropdown, which offers every tag already
used elsewhere in this asset. Picking from the list avoids the typo
that would otherwise split one exclusive set into two sets of one.
4. Repeat for every context in the set. Contexts outside the set need
nothing: an untagged context reports all of its overlaps, which is the
conservative default.
The same thing in code, for contexts built at runtime rather than authored:
using Scylla.Input;
var onFoot = new InputContext("OnFoot", priority: 0)
.EnableActionMaps("On Foot", "Global")
.AddConflictExclusiveTag("Modes");
var inCar = new InputContext("InCar", priority: 0)
.EnableActionMaps("In Car", "Global")
.AddConflictExclusiveTag("Modes");
var pause = new InputContext("Pause", priority: 90)
.EnableActionMaps("Pause Menu", "Global")
.SetBlocksLowerPriority(true)
.AddConflictExclusiveTag("Modes");
Three things about that example are worth stating plainly, because each one is a decision rather than an accident.
All three contexts carry the tag, not just the two locomotion ones. Exclusivity does not chain: tagging OnFoot with InCar and separately tagging InCar with Pause leaves OnFoot and Pause still reporting each other. Every member of a mutually exclusive set needs the shared tag. A context can hold several tags when it belongs to more than one set.
The Global map keeps reporting its overlaps, and that is correct. All three contexts enable it alongside their own map, so it is live whichever mode the player is in, and a key it shares with any of them really does fire both. This needs no special handling: a map that some single context enables together with another map is never treated as exclusive with it, whatever the tags say. Without that clause, tagging the contexts would silently hide every genuine collision against the always-on map.
Pause blocks lower-priority contexts, and that is not why its conflicts are suppressed. Priority, BlocksLowerPriority and ConsumesAllInput play no part in conflict reporting. The blocking flags decide which maps the module disables while the game runs; the tags decide what a controls screen tells the player. A context that blocks everything below it is still not exclusive with anything until a tag says so.
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 honors 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("Gameplay - On Foot", "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 sketch below covers 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 IReadOnlyList<string> ConflictExclusiveTags { get; }
/* Blocking flags (mutable; fluent setters below). */
public bool BlocksLowerPriority { get; set; }
public bool ConsumesAllInput { get; set; }
/* Conflict-exclusive tags (fluent, chainable). */
public InputContext AddConflictExclusiveTag(string tag);
public InputContext AddConflictExclusiveTags(params string[] tags);
public InputContext ClearConflictExclusiveTags();
public bool HasConflictExclusiveTag(string tag);
/* 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(). */
[Serializable]
public class InputContextDefinition
{
/* Serialized public fields, edited in the Inspector. */
public string ContextID;
public string DisplayName;
public string Description;
public List<string> ConflictExclusiveTags;
public int Priority; /* -100 to 100 */
public bool BlocksLowerPriority;
public bool ConsumesAllInput;
public List<string> EnabledActionMaps;
public List<string> DisabledActionMaps;
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.
Contexts included with the framework
A fresh ScyllaInputContextConfiguration asset is pre-populated with these five contexts, which match the default actions asset Scylla Input Actions - Default. Edit, remove, or extend them in the Inspector; “Reset to Defaults” restores them.
| Context ID | Priority | Blocks Lower | Consumes All | Enabled Action Maps | Notes |
|---|---|---|---|---|---|
Gameplay | 0 | No | No | Gameplay | Base context. Every other context stacks on top. |
Menu | 80 | Yes | No | Menu | Blocks gameplay input. Does not block Global (Global is priority 95, higher, so not strictly lower). |
Console | 90 | Yes | No | Console | Same shape as Menu but higher priority. Blocks both Gameplay and Menu when active above them. |
Global | 95 | No | No | Global, Console Global | Holds always-on hotkeys (screenshot, debug toggle, quit). Sits at the bottom of the stack for the game’s lifetime. Below Rebinding on purpose, so an interactive rebind outranks it; otherwise only a ConsumesAllInput context above it can shadow it. |
Rebinding | 100 | Yes | Yes | none | Push while an interactive rebind is running. Enables nothing and consumes everything, so the key the player presses reaches the rebind and nothing else. |
One configuration per game profile
A context enables and disables action maps by name, so a configuration only fits the actions asset whose maps it names. Each of the twelve factory assets therefore has a configuration written for it, and the Input Wizard copies the pair together when you pick a game profile. Point a configuration at an asset it was not written for and the mismatched entries do nothing: enabling a map that is not there is not an error, it is a lookup that finds nothing. The module logs a warning naming the context and the map the first time it happens, and the Contexts tab of the Input Actions Editor shows the same thing as a coverage table.
Every configuration also carries a Rebinding context. It is the one context that sets ConsumesAllInput, and it is the reason Global is the exception to its own always-on rule: while the player is binding a key, pressing the screenshot hotkey has to bind that key rather than take a screenshot. Push it around the interactive rebind and pop it after, and no other context needs to know a rebind is happening.
Most genres get one gameplay context plus the Menu, Console and Global backbone above, because everything the player does is live at once. Two of them are shaped differently, and they are the interesting cases:
- Point & Click / Adventure splits
ExplorationfromDialogue. Dialogue blocks lower-priority contexts, so a click during a conversation advances the line instead of walking the character across the room. - Survival / Sandbox splits
On FootfromBuilding: primary and secondary use become place and demolish in build mode.Gameplaystays enabled in both, because looking around, the hotbar and the crafting screens have to work either way.
Exclusivity tags keep a rebinding screen honest, and they take two settings that have to agree. Every context except Global and Rebinding carries the shared Mode tag, and every sidecar’s config layout sets DeriveExclusionsFromContexts. Without both, the conflict report flags every control two mutually exclusive modes reuse, and a player rebinding place-block is told it collides with primary-use. Global is untagged deliberately, because it really is live alongside everything else, so a control it shares is a genuine collision worth reporting.
Best Practices
-
Keep Global above every menu-style context and push it first. The combination of “higher priority than any blocking menu + no blocking + pushed at the bottom” keeps Global alive through every menu above it. Only Rebinding sits higher, by design. Reserve
ConsumesAllInputfor the rare modal cases that must shadow even Global. -
Use
BlocksLowerPriorityfor menus and pauses; reserveConsumesAllInputfor 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. -
Start from the five default contexts (Gameplay, Menu, Console, Global, Rebinding). A new project that adopts those names and priorities inherits behaviors that match the framework’s defaults and avoids invented-here-different-from-the-docs surprises.
-
Use
DisableActionMapsdeliberately, not by reflex. A context’sDisableActionMapslist 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’sEnableActionMapswork 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
InputContextChangedEventfor HUD swaps, not toPushed+Poppedseparately. 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
OnActivatedandOnDeactivatedfor 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 onContextID == "Dialog". -
Gate polled action reads with
ShouldProcessInputwhen 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. -
Give a shared tag to contexts the game design declares mutually exclusive. Conflict detection uses
ConflictExclusiveTagsto skip false-positive conflicts; without one, a player rebindingCancelin the Menu context sees a phantom conflict againstCancelin the Gameplay context even though those two are never active at the same time. Declare every context in the set, not just the obvious pair: exclusivity does not chain, so tagging Gameplay with Menu and Menu with Console leaves Gameplay and Console still reporting each other. -
Leave the always-on context untagged. A Global context that stays live under everything genuinely can fire alongside any other map, so its overlaps are real conflicts and the player should see them. This one needs no special handling: a map that some context enables alongside another map is never treated as exclusive with it, whatever the tags say.
-
Treat
RegisterContextandPushContextas 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 usingPushContext(string)for the rest of the game’s lifetime is faster and cleaner than pushingInputContextreferences each time. -
Call
ClearContextStackon 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
Related
- 01 Scylla Input
- 02 Getting Started
- 03 Input – Actions and Bindings
- 10 Core Architecture – Event System
- API reference:
Scylla.Input.IInputContextManager - API reference:
Scylla.Input.InputContext - API reference:
Scylla.Input.InputContextDefinition - API reference:
Scylla.Input.InputContextChangedEvent - API reference:
Scylla.Input.InputContextPushedEvent - API reference:
Scylla.Input.InputContextPoppedEvent