ZOA Input
Layered action-map contexts with real arbitration, so opening a menu over a moving player does the right thing.
Unity's Input System reads keys well. The problem starts one level up: a running game has several systems that each believe they should be handling the same button, and the honest answer to who wins changes from frame to frame. A pause menu is open over a first-person camera; the inventory is open over the pause menu; a dialogue box has focus while a vehicle is still under the player. Escape means something different in each of those states, and none of the systems involved can see the others.
ZOA Input answers that with a priority stack of named contexts and a set of arbitration rules layered on top of it. Contexts are pushed and popped as UI surfaces open and close, the highest-priority active context owns an action by default, and a per-action rule can override that default: always prefer a named context, block the action entirely while the conflict exists, or let every active context see it.
Underneath sits a thin abstraction over Unity's InputAction. Consumers resolve an InputActionId to an IInputActionBinding and ask it whether it was pressed this frame, never holding an InputActionReference of their own. The indirection lets an action be rebound at runtime, backed by a mocked double in tests, or fed from a different transport, with no consumer changing.
Depends on (2)
Depended on by (4)
How it works
Concepts
Contexts are a priority stack with a guaranteed floor
InputContextService keeps an ordered list of InputContextStackEntry, each carrying a context id, an integer priority and an active flag. ActiveContext is whichever active entry holds the highest priority. PushContext with an id already on the stack does not stack a duplicate: it reactivates the existing entry and overwrites its priority, which is why nested surfaces that need layering use distinct ids such as ui.pause and ui.settings rather than pushing ui twice.
The Gameplay context is special. The service constructor pushes it at the default priority of zero, IsContextActive returns true for it unconditionally, and ActiveContext falls back to it if the stack is somehow emptied. A UI context popping out from under gameplay therefore cannot leave the player unable to move: there is always a floor to fall back to.
Priority is the only ordering input. A menu pushed at priority ten wins over gameplay at zero; a modal pushed at twenty wins over the menu. Nothing about push order matters, which means two systems pushing in an unpredictable order still produce a deterministic winner.
Arbitration is resolved per action
Without a rule, an action belongs to ActiveContext and IsActionAllowed is true only for that context. That default is HighestPriority and it is right most of the time: the topmost surface consumes input, everything below is inert.
InputArbitrationRule overrides that for one action id. PreferSpecified names a context that always wins for this action while it is active, which is how a push-to-talk or a screenshot key keeps working from inside a menu. BlockAll suppresses the action for every context while the conflict exists, which is how a fire input is silenced during a cutscene without tearing down the gameplay context. PassThrough lets every active context see the action, and the caller is expected to iterate the active contexts itself if it cares which ones.
Rules are registered against the service, not baked into a binding set, so a system can install one when it comes up and the arbitration answer changes without any consumer's read path changing. ResolveOwner answers which context wins an action right now, and IsActionAllowed answers whether a specific context may act on it, which is the call a consumer actually makes before responding to a press.
Focus tokens, for surfaces that outlive each other
The context stack has one weakness: PopContext is addressed by id, so if two surfaces both push ui, whichever closes first pops the other's context out from under it. InputFocusService fixes that by handing back an opaque InputFocusToken per Acquire call and tracking each request independently. Releasing a token removes only that request, and ReleaseOwner tears down every request an object still holds, which is the safe teardown when a panel is destroyed mid-transition.
The winner is resolved by priority first, then by InputFocusLayer, then by acquisition sequence, so a later Modal beats an earlier Overlay at the same priority and the newest of two equal requests wins. New UI and menu code should prefer this service over calling PushContext and PopContext directly.
A focus request carries intent as data rather than acting on it. CursorPolicy says whether this surface wants the cursor locked or unlocked and SuppressesGameplay says whether gameplay input should be silenced, but the core service never touches Unity cursor state. InputFocusUnityBridge subscribes to the snapshot and translates that intent into the platform surfaces: cursor capture, the shared cursor-lock bus, and the input-suppression flag. The service also mirrors its active context into an InputContextService when one is supplied at construction, so both views agree.
The adapter is the composition root
ZOAInputAdapter is a MonoBehaviour on the locally-controlled pawn and it is where everything is assembled. On OnEnable it constructs an InputContextService, wraps it in an InputFocusService, builds the Unity bridge, walks its InputBindingSet materialising one InputAction per authored row, enables each and wraps it in an InputActionBinding, then publishes itself as IZOAInputBindings and its two services as IInputContextService and IInputFocusService.
Rows drive action construction directly. A Button row becomes an InputActionType.Button; Axis and Vector2 become Value actions with an expected control type; a composite row calls AddCompositeBinding with the authored composite type and attaches each named part; a plain row adds every binding path in its list, which is how keyboard-and-mouse and gamepad fan into one action.
Teardown is defensive in the way a scene reload demands. OnDisable only unregisters a service if the registry still points at this adapter's instance, because a split-screen sibling or a reloaded scene may already have replaced it, and disposing an action that Unity has already torn down is swallowed rather than thrown. SetBindingSet rebuilds the whole graph at runtime and re-registers, and publishToRegistry can be turned off so a second adapter does not fight the first for the registration.
The adapter registers its context service even when no binding set is assigned, so gameplay code can push and pop contexts before any bindings exist.
Binding sets are authored content
InputBindingSet is a DefinitionBase ScriptableObject with a stable id, a target context id defaulting to gameplay, an advisory version string, an on-disk schema revision, and a list of InputBindingRow. A genre bundle points at one by id, and the package ships nine sample sets, one per flagship feature set, sharing the same WASD plus mouse plus gamepad map with theme-flavoured metadata so authors can customise from a working baseline rather than an empty asset.
A row is authored data only: the canonical action id, a display name for the rebind UI, a control type, and either a list of binding paths or a composite type with named parts. IsAuthored is the guard the adapter uses to skip half-filled rows, so an in-progress asset does not throw at play-mode start.
Glyphs and rebinding sit on top
InputGlyphService answers the question a prompt needs: which icon do I show for this action on the device the player is holding. Rebuild walks one or more InputActionAssets and, for every non-composite binding, derives a device family from the control path, a normalised lowercase glyph key such as gamepad/buttonsouth that an icon atlas keys off, and the Input System's own human-readable label as a text fallback. Lookups are dictionary-backed and allocation-free on the hot path, and Rebuild is called again after a rebind so glyphs never drift from the live bindings.
The editor exporters use the same classifier, so a Steam Input manifest and a shipped glyph catalog are generated from the classification the runtime uses and cannot disagree with what the player sees.
InputRebindMenu is the runtime rebind panel, built on UI Toolkit, driving Unity's PerformInteractiveRebinding and persisting the result through InputOverrideStore. Overrides are keyed by binding-set id, action id and binding index and stored as one JSON blob per set under a PlayerPrefs key, so two binding sets cannot collide and a set costs one round trip rather than one per action.
In the editor
Screens
Screenshot pending
/screenshots/rebind-menu.png
InputRebindMenu open in play mode showing several action rows with their current binding paths, one row mid-capture with its button showing the waiting-for-input state.
Screenshot pending
/screenshots/input-mapping-wizard.png
The wizard on its actions or arbitration step, step rail visible on the left, a few authored actions listed with control types and default bindings.
Screenshot pending
/screenshots/context-browser.png
The Input module's Context Browser capability listing the built-in contexts with their priorities, so the stack model is visible without reading code.
Screenshot pending
/screenshots/glyph-prompt-in-play.png
An in-game interaction prompt captured twice, once with a keyboard binding label and once with the gamepad glyph for the same action, showing ActiveFamily switching the presentation.
Setup
Workflow
- 01
Author a binding set
Create an InputBindingSet through ZOA/Input/Input Binding Set, or import one of the nine themed samples and edit from there. Give it a stable id, leave the context at gameplay unless the set targets a menu-only pawn, and author one row per canonical action: move, look, fire, reload, interact, weapon wheel, jump, sprint, crouch at minimum for a playable default.
- 02
Put the adapter on the pawn
Add a ZOAInputAdapter to the locally-controlled rig and assign the binding set. Leave publishToRegistry on for a single-player rig and turn it off on secondary rigs in split-screen so only one adapter owns the registration. Set an initial context id only when this pawn starts somewhere other than gameplay.
- 03
Resolve bindings, do not hold references
In consumer code resolve IZOAInputBindings from the registry and call TryGetBinding for the action you need. Treat a false return as the action not being available right now rather than as a failure, and re-resolve after a binding-set swap.
- 04
Acquire focus when a surface opens
When a menu, inventory or dialogue panel opens, acquire an InputFocusRequest naming its context, its layer and its cursor policy, and keep the token. Release that token when the surface closes, or call ReleaseOwner from OnDestroy so a teardown mid-transition cannot leave the stack stuck.
- 05
Register arbitration rules for the exceptions
The priority default covers the common case. Register a rule only where the honest answer differs: a push-to-talk that must survive a menu, a fire input that must be blocked during a cutscene, a debug key several contexts should all see.
- 06
Wire prompts and rebinding
Call InputGlyphService.Ensure at bootstrap and Rebuild with the project's action assets, then resolve glyphs for prompts through the service. Enable the ZOA_INPUT_REBIND_UI define if the project ships a rebind screen, and let InputRebindMenu apply persisted overrides on start.
Surface
Key types
InputActionId
struct
Strongly typed action identifier wrapping a string. Ordinal equality, an IsValid guard against empty values, and equality operators so it works as a dictionary key.
- InputActionId(string value)
- string Value { get; } bool IsValid { get; }
InputContextId
struct
Strongly typed context identifier. Same shape as InputActionId; DefaultContexts holds the built-in values.
- InputContextId(string value)
- string Value { get; } bool IsValid { get; }
DefaultContexts
class
The built-in context ids for common gameplay states. Gameplay is the guaranteed floor of every stack.
- static readonly InputContextId Gameplay, UI, Vehicle
- static readonly InputContextId Inventory, Dialogue, Spectator, Debug
IInputActionBinding
interface
Read-only view of one action, abstracted from the underlying input system. This is what a weapon controller or an interaction trigger holds instead of an InputActionReference.
- InputActionId ActionId { get; } InputContextId OwnerContext { get; }
- bool IsPressed { get; }
- bool WasPressedThisFrame { get; } bool WasReleasedThisFrame { get; }
- float ReadValue() Vector2 ReadVector2()
InputActionBinding
class
The concrete wrapper over a Unity InputAction. Every read guards on the action being enabled, and the value reads catch the control-type mismatch that compound bindings can throw, returning zero rather than letting an exception escape an Update loop.
- InputActionBinding(InputActionId actionId, InputContextId ownerContext, InputAction action)
IZOAInputBindings
interface
The binding-lookup service. A failed resolve means the action is not authored in the active set, which callers treat as not available in this context rather than as an error.
- bool TryGetBinding(InputActionId actionId, out IInputActionBinding binding)
- bool TryGetBinding(string actionId, out IInputActionBinding binding)
IInputContextService
interface
Layered context management with arbitration. Push and pop as surfaces open and close; register a rule to change how one action resolves.
- void PushContext(InputContextId contextId, int priority = 0)
- void PopContext(InputContextId contextId)
- InputContextId ActiveContext { get; }
- bool IsContextActive(InputContextId contextId)
- void RegisterArbitrationRule(InputArbitrationRule rule)
InputContextService
class
The concrete stack. Adds the two resolution queries the interface does not expose, and guarantees the Gameplay baseline is always present.
- const int DefaultPriority = 0
- InputContextId ResolveOwner(InputActionId actionId)
- bool IsActionAllowed(InputContextId contextId, InputActionId actionId)
InputArbitrationRule
class
One action's conflict-resolution policy: the action it governs, the context it prefers, and the strategy to apply.
- InputArbitrationRule(InputActionId actionId, InputContextId preferredContext, InputArbitrationStrategy strategy = InputArbitrationStrategy.HighestPriority)
- InputActionId ActionId { get; } InputContextId PreferredContext { get; } InputArbitrationStrategy Strategy { get; }
InputArbitrationStrategy
enum
How a contested action resolves. HighestPriority is the default when no rule is registered.
- HighestPriority — the top active context wins
- PreferSpecified — the named context wins whenever it is active
- BlockAll — nobody gets the action while the conflict exists
- PassThrough — every active context sees it
IInputFocusService
interface
Owner-token focus stack. Each request is tracked independently, so nested surfaces release their own claim without disturbing anyone else's.
- InputFocusToken Acquire(InputFocusRequest request)
- bool Release(InputFocusToken token) int ReleaseOwner(object owner)
- bool IsActive(InputFocusToken token)
- InputFocusSnapshot Snapshot { get; } InputContextId ActiveContext { get; }
- event Action<InputFocusSnapshot> Changed
InputFocusRequest
struct
What a surface asks for: a context, an owner, a priority, a layer, whether the cursor should lock or unlock, whether gameplay should be suppressed, and a reason string for diagnostics.
- InputFocusRequest(InputContextId contextId, object owner = null, int priority = 0, InputFocusLayer layer = InputFocusLayer.Overlay, InputFocusCursorPolicy cursorPolicy = InputFocusCursorPolicy.Unchanged, bool suppressesGameplay = false, string reason = null)
- enum InputFocusLayer { Gameplay = 0, Overlay = 10, Modal = 20, System = 30 }
- enum InputFocusCursorPolicy { Unchanged, Lock, Unlock }
InputFocusSnapshot
class
The resolved focus state handed to subscribers: the winning entry, every live entry, and the derived context, cursor policy and gameplay-suppression flag.
- InputFocusEntry? ActiveEntry { get; } IReadOnlyList<InputFocusEntry> Entries { get; }
- InputContextId ActiveContext { get; }
- InputFocusCursorPolicy CursorPolicy { get; } bool SuppressesGameplay { get; }
ZOAInputAdapter
component
The composition root on the player rig. Materialises the binding set into Unity InputActions, owns the context and focus services, and publishes all three into the service registry. Added under ZOA/Input/ZOA Input Adapter.
- InputBindingSet BindingSet { get; } void SetBindingSet(InputBindingSet newSet)
- InputContextService ContextService { get; } InputFocusService FocusService { get; }
- bool TryGetBinding(InputActionId actionId, out IInputActionBinding binding)
- serialized: bindingSet, publishToRegistry, initialContextId, initialContextPriority
InputFocusUnityBridge
class
Translates focus intent into Unity behaviour: cursor capture, the cursor-lock bus, and input suppression. Constructed by the adapter and disposed with it.
- InputFocusUnityBridge(IInputFocusService focusService, UnityEngine.Object cursorOwner, object suppressionOwner = null)
- bool CursorCaptured { get; } bool InputSuppressed { get; }
- void Dispose()
InputBindingRow
struct
One canonical action and every binding path that activates it. Supports single paths, multi-path fan-in across device families, and composites through named CompositePart entries.
- string actionId string displayName InputControlType controlType
- List<string> bindingPaths
- bool isComposite string compositeType List<CompositePart> compositeParts
- bool IsAuthored()
- enum InputControlType { Button, Axis, Vector2 }
IInputGlyphService
interface
Glyph metadata lookup plus active-device tracking. HUD prompts, tutorials and the rebind menu resolve through it instead of parsing control paths themselves.
- InputDeviceFamily ActiveFamily { get; }
- IReadOnlyList<InputGlyph> GlyphsFor(string actionId)
- bool TryGetGlyph(string actionId, InputDeviceFamily family, out InputGlyph glyph)
- IReadOnlyCollection<string> ActionIds { get; }
InputGlyphService
service
The default glyph service, built from InputActionAssets. Ensure resolves or creates the registered instance; Rebuild replaces the table wholesale after a rebind. Its path classifiers are public and are what the editor exporters use.
- static InputGlyphService Ensure()
- void Rebuild(params InputActionAsset[] assets)
- static InputDeviceFamily ClassifyControlPath(string controlPath)
- static string NormalizeGlyphKey(string controlPath)
InputGlyph
struct
One action-binding glyph entry: the action id, its device family, the normalised semantic glyph key an atlas resolves, and a human-readable label for text fallbacks. Never a sprite reference.
- string ActionId InputDeviceFamily Family string GlyphKey string DisplayName
- enum InputDeviceFamily { Unknown, KeyboardMouse, XboxGamepad, PlayStationGamepad, NintendoGamepad, GenericGamepad, SteamDeck, Touch }
InputOverrideStore
class
PlayerPrefs-backed persistence for user rebinds. One JSON blob per binding-set id, so two sets never collide and saving a set is one write rather than one per action.
- static void Save(string bindingSetId, IEnumerable<(string actionId, int bindingIndex, string overridePath)> entries)
- static IEnumerable<(string actionId, int bindingIndex, string overridePath)> Load(string bindingSetId)
- static void Clear(string bindingSetId)
InputRebindMenu
component
The runtime rebind panel. Enumerates the rows published on the active bindings, captures a new control through Unity's interactive rebinding, and persists the override. Requires a UIDocument sibling and compiles only under the ZOA_INPUT_REBIND_UI define. Added under ZOA/Input/Runtime Rebind Menu.
- serialized: headingText, bindingSetIdOverride, applyOverridesOnStart
Surface
Authoring assets
InputBindingSet
asset
The bundled binding-set asset: a stable id, the context it targets, and one row per canonical action. A genre bundle references one by id and the adapter loads it at play-mode start to build its InputActions. Created via ZOA/Input/Input Binding Set.
- string BindingSetId { get; } string Version { get; } int SchemaVersion { get; }
- string ContextId { get; } IReadOnlyList<InputBindingRow> Rows { get; }
- bool TryGetRow(string actionId, out InputBindingRow row)
- const int CurrentSchemaVersion = 1
Usage
Examples
using UnityEngine;
using ZOA.Input.Core;
using ZOA.Messaging;
public sealed class WeaponTrigger : MonoBehaviour
{
private static readonly InputActionId Fire = new InputActionId("fire");
private static readonly InputActionId Aim = new InputActionId("aim");
private IZOAInputBindings _bindings;
private void OnEnable()
{
FoundryServiceRegistry.TryResolve<IZOAInputBindings>(out _bindings);
}
private void Update()
{
if (_bindings == null) return;
// A false return means the action is not authored in the active
// binding set. That is a normal state, not an error.
if (_bindings.TryGetBinding(Fire, out var fire) && fire.WasPressedThisFrame)
Shoot();
if (_bindings.TryGetBinding(Aim, out var aim))
SetAds(aim.IsPressed);
}
private void Shoot() { /* ... */ }
private void SetAds(bool active) { /* ... */ }
}using ZOA.Input.Core;
using ZOA.Messaging;
var contexts = FoundryServiceRegistry.Get<IInputContextService>();
// Nested surfaces use distinct ids: pushing "ui" twice would
// reactivate one entry rather than layering two.
contexts.PushContext(new InputContextId("ui.pause"), priority: 10);
contexts.PushContext(new InputContextId("ui.settings"), priority: 20);
// Push-to-talk must survive a menu, so name the context that always
// wins for it rather than relying on stack priority.
contexts.RegisterArbitrationRule(new InputArbitrationRule(
new InputActionId("pushToTalk"),
DefaultContexts.Gameplay,
InputArbitrationStrategy.PreferSpecified));
// Nobody fires while the cutscene runs, and no context has to be
// torn down to make that true.
contexts.RegisterArbitrationRule(new InputArbitrationRule(
new InputActionId("fire"),
DefaultContexts.Gameplay,
InputArbitrationStrategy.BlockAll));
// The concrete service exposes the two resolution queries a
// consumer actually asks.
var service = (InputContextService)contexts;
bool gameplayMayFire = service.IsActionAllowed(
DefaultContexts.Gameplay, new InputActionId("fire")); // false
InputContextId owner = service.ResolveOwner(new InputActionId("interact"));
// Closing the settings panel leaves the pause context in place.
contexts.PopContext(new InputContextId("ui.settings"));using UnityEngine;
using ZOA.Input.Core;
using ZOA.Messaging;
public sealed class InventoryPanel : MonoBehaviour
{
private IInputFocusService _focus;
private InputFocusToken _token;
private void OnEnable()
{
if (!FoundryServiceRegistry.TryResolve<IInputFocusService>(out _focus))
return;
_token = _focus.Acquire(new InputFocusRequest(
contextId: DefaultContexts.Inventory,
owner: this,
priority: 20,
layer: InputFocusLayer.Modal,
cursorPolicy: InputFocusCursorPolicy.Unlock,
suppressesGameplay: true,
reason: "inventory open"));
}
private void OnDisable()
{
// Releasing our own token cannot disturb another surface's claim.
_focus?.Release(_token);
}
private void OnDestroy()
{
// Belt and braces for a teardown mid-transition.
_focus?.ReleaseOwner(this);
}
}using UnityEngine;
using ZOA.Input.Unity.Glyphs;
using ZOA.Messaging;
public sealed class InteractPrompt : MonoBehaviour
{
private IInputGlyphService _glyphs;
private void OnEnable()
{
FoundryServiceRegistry.TryResolve<IInputGlyphService>(out _glyphs);
}
public string PromptText()
{
if (_glyphs == null) return "Interact";
// ActiveFamily tracks the most recently used device, so the
// prompt follows the player from keyboard to gamepad.
if (_glyphs.TryGetGlyph("Gameplay/Interact", _glyphs.ActiveFamily, out var glyph))
{
// GlyphKey is a semantic id an icon atlas resolves, never a
// sprite reference. DisplayName is the text fallback.
return "Press " + glyph.DisplayName + " to interact";
}
return "Interact";
}
}using UnityEngine;
using ZOA.Input.Unity;
public sealed class GenreBundleApplier : MonoBehaviour
{
[SerializeField] private ZOAInputAdapter adapter;
public void Apply(InputBindingSet set)
{
// Rebuild tears down every owned InputAction, re-materialises
// the new set, and re-registers the services. Consumers holding
// an IInputActionBinding must re-resolve afterwards.
adapter.SetBindingSet(set);
}
}Tooling
Editor tools
Input Mapping Wizard
Tools > ZOA > Advanced > Define > Characters > Input > Input Mapping Wizard
Steps through selecting a context, defining actions, setting arbitration, and saving. On Finish it emits an InputBindingSet asset to the chosen path, which makes it the canonical authoring surface for binding sets rather than a preview tool. It routes into the Workbench shell with the step rail, issue tray and preview dock, and EditExisting reopens it against an asset already on disk.
Input Workbench module
ZOA Platform Workbench > Input
Three capabilities in one module: Binding Sets to browse, edit, validate, duplicate and delete binding sets; Context Browser to inspect the built-in and registered contexts; and an Overview of input system status and architecture.
Export Glyph Catalog (JSON)
Tools > ZOA > Advanced > Generate > Input > Export Glyph Catalog (JSON)
Emits the glyph metadata for one or more InputActionAssets as a versioned JSON catalog for platform glyph packs and external tooling. Classification is delegated to InputGlyphService, so an exported catalog cannot drift from the runtime lookup.
Export Steam Input Manifest (VDF)
Tools > ZOA > Advanced > Generate > Input > Export Steam Input Manifest (VDF)
Generates a Steam Input In-Game Actions manifest from an InputActionAsset: each action map becomes an action set, Vector2 actions become StickPadGyro entries, Axis actions become AnalogTrigger entries, everything else becomes a Button, and a flat English localization block is emitted from the action names. Pure text generation with no Steamworks dependency, so it stays unit-testable.
Read this
Notes and caveats
See also