Corecom.zoa.input · v0.2.0

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.

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.

The runtime rebind menu

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.

The Input Mapping Wizard

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.

The Workbench Context Browser

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.

A device-aware prompt in play

Setup

Workflow

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

Reading an action through the abstractioncsharp
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) { /* ... */ }
}
No InputActionReference field anywhere. The action stays reboundable at runtime and mockable in a test.
Pushing a context and arbitrating one actioncsharp
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"));
PopContext removes by id, so unrelated surfaces must not share one. The focus service removes that constraint.
A UI surface taking focus with a tokencsharp
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);
    }
}
The request carries cursor and suppression intent as data. InputFocusUnityBridge is what actually touches the cursor.
Resolving a glyph for an interaction promptcsharp
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";
    }
}
TryGetGlyph falls back to any family when the exact one has no binding, and returns false only when the action itself is unknown.
Swapping the binding set at runtimecsharp
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);
    }
}
Bindings handed out before the swap point at disposed actions. Re-resolve through TryGetBinding rather than caching the wrapper across a rebuild.

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