Everything you need to let players remap controls at runtime, from the first ‘press any key’ prompt to conflict resolution, profile persistence, and a full options-screen data model.
Summary
Runtime rebinding comes in two layers, and most projects use both. IInputBindingManager, accessed through ScyllaInput.Instance.BindingManager, is the low-level one: single-binding rebind operations, conflict detection, override read/write, and the storage layer underneath them. InputRebindManager sits above it as a concrete class instantiated per options screen, building a complete, UI-agnostic data model organized by control-scheme columns and action rows, with batch operations and auto-resolve helpers baked in. Between them they cover the whole span from the player clicking Rebind Jump in the options menu to the new binding surviving a relaunch with the right key still mapped.
Conflicts, persistence, and cancelation are all handled in that lower layer. GetConflicts returns every existing binding that occupies a candidate path, and an interactive rebind reports the bindings it collides with as it completes, so a duplicate can be shown to the player rather than silently double-bound. Saves and loads run through a pluggable IBindingStorage, which puts a JSON file and a PlayerPrefs entry one swap apart. Rebind operations carry an explicit cancel path and a configurable timeout, and InputRebindManager adds a completion cooldown, so the click that finishes a mouse-button rebind cannot immediately trigger another one.
Bindings in the framework are overrides. The authoritative defaults live in the project’s InputActionAsset; only the deviations from those defaults are saved to disk, loaded back at startup, and reset when the player asks for it. This means a patch can change a default binding without breaking saved profiles, and “Reset to Defaults” is a single call (ResetAllToDefaults) that drops every override at once.
When you need to build a full configuration screen, InputRebindManager is the right shape. You hand it the action manager and the binding manager in its constructor, then describe the screen with a ConfigLayout: which control schemes appear as columns (WASD, Gamepad, IJKL), which actions group under which heading, and which of those groups can never be live together. The output is a flat per-action data model that drops directly into UI: one row per action, one cell per scheme group, with conflict state, override state, display strings, and rebind start/cancel helpers all pre-computed. The bundled ConfigScreen demo is the canonical reference for how the two layers compose into a player-facing screen.
Use this page for:
- Triggering a single-binding rebind from a button click (“Rebind Jump”).
- Warning the player about a conflicting binding and letting them keep or undo the change.
- Persisting binding overrides per-profile, with JSON-on-disk or PlayerPrefs storage.
- Switching storage backends at runtime (a JSON exporter for modding, a PlayerPrefs fallback for consoles).
- Building a full ConfigScreen-style remapping UI with scheme columns, conflict highlights, and reset-to-defaults.
- Reacting to rebind events on the SEX (Scylla Event eXchange) bus to update HUD prompts and re-cache button labels.
- Importing or exporting binding profiles as JSON for cloud-save or cross-device flows.
Features
-
Two-layer API.
IInputBindingManagerhandles single-binding operations at the low level (StartRebind,GetConflicts,SaveBindings,LoadBindings).InputRebindManager, a concrete class you instantiate, builds a full ConfigScreen data model at the high level (aConfigLayoutof columns and groups, batch reset, auto-resolve, post-completion cooldown). -
Override-only persistence. The
InputActionAssetdefaults are authoritative; only the player’s deviations are serialized. A patch that changes a default does not invalidate saved profiles. -
Pluggable storage. The backend is chosen by the Input configuration’s Binding Storage settings, which default to
JSONFileBindingStorage: one file per profile, written asbindings_{profileID}.jsonunderApplication.persistentDataPath/Scylla/Input/. Setting the storage type to PlayerPrefs selectsPlayerPrefsBindingStorageinstead. Code can override either at runtime withBindingManager.SetStorage(...), and custom backends implementIBindingStorage. -
Profile system.
CurrentProfileID(default"default") is the implicit target forSaveBindings/LoadBindings.SetCurrentProfile,GetAllProfiles, andDeleteProfilecover the profile UI. Saved profiles persist across runs as long as the storage backend keeps the data. -
Conflict detection.
GetConflicts(path, excludeActionID)returns every existing binding that occupies the candidate path.BindingConflictcarries the conflicting action ID, map ID, binding index, control path, and a display string ready for a UI label. Conflict detection respects the conflict-exclusive tags on your input contexts when the context manager is supplied. -
Configurable rebind operations.
InputRebindOperationhas an explicit cancel control path, a timeout, optional exclusion of mouse position and mouse delta, and callbacks onOnConflictDetectedandOnComplete. Every operation starts from the Rebind Settings in the Input configuration (5 seconds and<Keyboard>/escapeby default), and the fluent surface allows tweaks per call site. -
SEX bus events. Five events,
InputRebindStartedEvent,InputRebindCompletedEvent,InputRebindCancelledEvent,InputBindingsSavedEvent, andInputBindingsLoadedEvent, cover every meaningful transition. HUD code subscribes to the saved/loaded pair to refresh button prompts after a rebind without polling. -
Managed-layer events.
InputRebindManagerpublishes its own richer set:ManagedRebindStartedEvent,ManagedRebindCompletedEvent,ManagedRebindCancelledEventandManagedRebindDataRefreshedEvent. They carry the map, the scheme group and the device type alongside the action, so an options screen can repaint one cell instead of rebuilding the whole grid. -
JSON export/import.
ExportBindingsAsJSONandImportBindingsFromJSONround-trip the override set as a pretty-printed JSON document with version, profile ID, timestamp, and the override array. Same payload the storage backends save. -
Post-completion cooldown.
InputRebindManagerenforces a 200 ms cooldown between a rebind completing and the next rebind starting, which prevents the mouse-button press that finished one rebind from immediately re-triggering another via Unity UI’s pointer-upOnClick. -
Optional auto-load at startup. Enable Auto Load Bindings in the configuration’s Binding Storage settings and the module restores the saved profile during initialization, after the configured backend is installed and the configured
DefaultProfileIDhas become the current profile. It is on by default, andIInputBindingManager.Initializedeliberately loads nothing itself: doing so would run before any caller could install the backend it actually wanted. -
Auto-resolve conflicts.
InputRebindManager.AutoResolveConflicts()walks the data model and remaps colliding bindings to available keys, returning anAutoResolveResultthat lists every change made and how many conflicts remain. -
Per-action rebindability. Actions annotated in an actions asset’s metadata sidecar can be
Rebindable,ProtectedorHidden. PassScyllaInput.Instance.Rebindabilityas the second argument toInitializeand a hidden action never becomes a row, while a protected one keeps its row, shows its bindings and refuses to rebind. The refusal lives in the manager, so a screen that forgets to gray the cell still cannot rebind through it. Without a provider, every action stays rebindable, matching how the manager behaved before this feature existed. -
Interaction-aware and scheme-aware conflict reporting. Two bindings on one control whose interactions cannot both perform from a single press are not a collision, so
GetConflictshas an overload taking the querying binding’sInputInteractionSignature. Prefer it wherever the querying binding is known: the overload without it has to assume Unity’s default interaction, which overlaps everything, and so reports pairs the actions editor would not. Build the argument withInputInteractionResolver.Shared.ResolveForBinding, which accounts for an override replacing the authored interactions, the action’s own being added, and a composite part carrying its container’s. Separately, the third argument toInitializesays whether control schemes are mutually exclusive. A game that applies a scheme as a binding mask has only one layout live at a time, so three keyboard layouts may each use the same key for something different; passtrueand those stop being reported. It mirrors the “Schemes are conflict-exclusive” setting on the actions editor’s Schemes tab, and defaults to false.
Concepts
The vocabulary used throughout the page.
| Concept | Type / shape | Notes |
|---|---|---|
IInputBindingManager | interface, reached via ScyllaInput.Instance.BindingManager | The low-level surface: rebind operations, conflict detection, override read/write, profile persistence, JSON export/import. Owned by the module; do not instantiate directly. |
InputRebindManager | concrete class, instantiated by user code | The high-level UI-agnostic facade for a full ConfigScreen. Takes the action manager and the binding manager in its constructor; call Initialize(layout) with a ConfigLayout to build the data model, or Initialize(layout, rebindability) to also honor per-action rebindability. Disposable. |
| Override | per-binding overridePath | The player’s deviation from the default. Stored separately from defaults; cleared by ClearBindingOverride or ResetToDefaults. Only overrides are saved. |
InputRebindOperation | per-rebind session object | Returned by BindingManager.StartRebind(mapID, actionID, bindingIndex). Listens for a new input, validates against the cancel control path, fires OnConflictDetected and OnComplete. Single active operation per BindingManager. |
BindingConflict | immutable class, get-only properties | One entry returned by GetConflicts(path). Carries ConflictingActionID, ConflictingMapID, ConflictingBindingIndex, ConflictingPath, DisplayString. Pass the action + index back to ClearBindingOverride to resolve. |
| Profile | named string ID | Groups overrides for the same player or controller setup. Default is "default". Switched via SetCurrentProfile; each profile saves under its own key in the storage backend. |
IBindingStorage | pluggable backend interface | Decouples the binding manager from any specific persistence mechanism. Methods: SaveBindings(profile, json), LoadBindings(profile) -> json, DeleteBindings, HasBindings, GetAllProfiles. |
PlayerPrefsBindingStorage | alternative storage backend | Stores under PlayerPrefs keys prefixed Scylla.Input.Bindings.. Selected by setting the configuration’s storage type to PlayerPrefs. Cross-platform; subject to PlayerPrefs size limits on some platforms. |
JSONFileBindingStorage | default storage backend | Writes bindings_{profileID}.json under Application.persistentDataPath/Scylla/Input/. Human-readable; suitable for modding and external editing. Custom base paths are supported via the constructor and via the configuration’s JSON storage folder. |
ConfigLayout | immutable declaration, built through ConfigLayout.Create() | What the screen is made of: its columns, its row groups, and which of those groups can never be live together. Passed once to InputRebindManager.Initialize. |
ConfigSchemeGroup | one column of a ConfigLayout | Declares which control scheme appears as a column in the data model. A project that supports keyboard, IJKL alternate, and gamepad would declare three columns. |
ConfigActionGroup | one row group of a ConfigLayout | A named set of actions shown under one heading, declared either by action map (WithGroup) or by explicit action IDs (WithGroupOfActions). Also the unit that conflict scoping works in. |
ConfigGroupExclusion | a set of group IDs that are never live together | Suppresses conflicts between the groups it names. Declared directly with WithExclusiveGroups, or derived from the game’s input contexts with WithExclusiveGroupsFromContexts. |
Recipes
What follows spans the whole rebinding surface, from a single “Rebind Jump” button up to a complete options screen, with every recipe usable on its own. Start with “Let a player rebind a single action” if you have not used this API before: it lays the groundwork the later recipes build on.
Let a player rebind a single action
Problem. Your options menu has a “Rebind Jump” button. The player clicks it, the game waits for a keypress, and the new binding sticks for the rest of the session and across relaunches. That is the entire flow for the vast majority of games that offer any remapping at all.
Solution. One StartRebind call plus a completion handler. The binding manager rejects concurrent rebinds and applies the override to the live action by the time the handler fires, so success means the change is already active.
using Scylla.Core;
using Scylla.Input;
using UnityEngine;
public sealed class RebindButton : MonoBehaviour
{
[SerializeField] private string _mapID = "Gameplay - On Foot";
[SerializeField] private string _actionID = "Jump";
[SerializeField] private int _bindingIndex = 0;
public void OnClick()
{
var binding = ScyllaInput.Instance.BindingManager;
/* Reject concurrent rebinds at the UI layer too. The manager would
* also reject, but matching the gate in UI gives the click a clean
* no-op outcome rather than a warning log. */
if (binding.IsRebinding) return;
/* StartRebind returns null when the rebind cannot start: manager not
* initialized, action not registered, bindingIndex out of range,
* or another rebind already running. */
var op = binding.StartRebind(_mapID, _actionID, _bindingIndex);
if (op == null)
{
Debug.LogWarning($"Could not start rebind for {_mapID}/{_actionID}[{_bindingIndex}]");
return;
}
/* OnComplete fires whether the operation succeeded, was canceled
* (Escape pressed by default), or timed out.
* Parameters: success flag, new path (null on cancel/timeout). */
op.OnComplete += (success, newPath) =>
{
if (success)
{
/* The override is already applied to the Unity action.
* Persist it so the next launch starts with the new binding. */
binding.SaveBindings();
Debug.Log($"Rebound {_mapID}/{_actionID} to {newPath}");
}
else
{
Debug.Log("Rebind canceled or timed out.");
}
};
}
}
Detect and resolve binding conflicts before committing
Problem. There is always a player who tries to rebind two actions to the same key by accident. The cheap path is to detect the conflict, surface it as a UI prompt (“Rebinding to Space will replace Crouch”), and let the player either keep the change or back out of it.
Solution. When the player presses a valid input, the operation applies the new binding, fires OnConflictDetected with every BindingConflict that shares the new path, and then fires OnComplete. The new binding is already live when the conflict handler runs, so backing out means restoring the operation’s OriginalPath. Hold off on saving until the player has decided.
using Scylla.Input;
using System.Collections.Generic;
using UnityEngine;
public sealed class ConfigCellController : MonoBehaviour
{
[SerializeField] private string _mapID;
[SerializeField] private string _actionID;
[SerializeField] private int _bindingIndex;
public void OnRebindClicked()
{
var binding = ScyllaInput.Instance.BindingManager;
if (binding.IsRebinding) return;
var op = binding.StartRebind(_mapID, _actionID, _bindingIndex);
if (op == null) return;
/* OnConflictDetected fires right after the new binding is applied and
* just before OnComplete. The argument is the list of every
* BindingConflict that shares the new path. */
IReadOnlyList<BindingConflict> pending = null;
op.OnConflictDetected += conflicts => pending = conflicts;
var originalPath = op.OriginalPath;
op.OnComplete += (success, newPath) =>
{
if (!success) return;
if (pending == null || pending.Count == 0)
{
binding.SaveBindings();
return;
}
/* Show a modal: "Rebinding to Space will replace Crouch.
* Continue? [Replace] [Cancel]" */
ShowConflictModal(
pending,
onReplace: () =>
{
/* Remove each competing binding's player override so the
* new path is owned by this action alone. A conflict that
* comes from an authored default needs SetBindingOverride
* to move it elsewhere instead. */
foreach (var conflict in pending)
{
binding.ClearBindingOverride(
conflict.ConflictingMapID,
conflict.ConflictingActionID,
conflict.ConflictingBindingIndex);
}
binding.SaveBindings();
},
onCancel: () =>
{
/* Put the binding back the way it was before the rebind. */
binding.SetBindingOverride(_mapID, _actionID, _bindingIndex, originalPath);
});
};
}
private void ShowConflictModal(
IReadOnlyList<BindingConflict> conflicts,
System.Action onReplace,
System.Action onCancel)
{
/* UI modal omitted for brevity. Each BindingConflict carries the
* conflicting action ID, map ID, binding index, control path, and
* display string ready to paste into the modal's body text. */
}
}
Declare which parts of a controls screen can never collide
Problem. A controls screen shows every action in the game at once, so it shows combinations the player can never reach at once. Jump on Space and Handbrake on Space are the same key in two modes that never coexist. Arrow keys drive movement and menu navigation. Reported literally, half the screen turns red on open and the Auto-Resolve button offers to fix problems that are not problems, which trains players to ignore the one marker that matters.
Solution. A ConfigLayout declares three things about the screen: its columns (control schemes), its row groups (usually one per action map), and which of those groups can never be live together. InputRebindManager consults that last part when deciding whether two bindings sharing a control are a real collision.
The exclusivity can be stated two ways, and they compose.
using Scylla.Input;
/* Option A: name the mutually exclusive groups directly. */
var layout = ConfigLayout.Create()
.WithColumn("Keyboard&Mouse", "Keyboard")
.WithColumn("Gamepad", "Gamepad", "<Gamepad>")
.WithGroup("OnFoot", "On Foot", "On Foot")
.WithGroup("InCar", "In Car", "In Car")
.WithGroup("Menu", "Menu", "Pause Menu")
.WithGroup("Global", "Global", "Global")
.WithExclusiveGroups("OnFoot", "InCar", "Menu")
.Build();
/* Option B: read it from the contexts, which already know. */
var layout = ConfigLayout.Create()
.WithColumn("Keyboard&Mouse", "Keyboard")
.WithColumn("Gamepad", "Gamepad", "<Gamepad>")
.WithGroup("OnFoot", "On Foot", "On Foot")
.WithGroup("InCar", "In Car", "In Car")
.WithGroup("Menu", "Menu", "Pause Menu")
.WithGroup("Global", "Global", "Global")
.WithExclusiveGroupsFromContexts(ScyllaInput.Instance.ContextManager)
.Build();
var config = new InputRebindManager(
ScyllaInput.Instance.ActionManager,
ScyllaInput.Instance.BindingManager);
config.Initialize(layout);
Option B is usually the better one. A game that uses contexts has already declared which modes exclude which, as conflict-exclusive tags on the contexts themselves, and restating the same relationships here gives you two declarations that can drift apart. WithExclusiveGroupsFromContexts reads the tags and emits the same exclusion sets Option A would have produced by hand. The result is indistinguishable: derived sets are ordinary exclusions, and both calls can appear in the same builder chain when part of the layout is context-driven and part is not.
Note what Option B does not do. It reads the registered context definitions once, while the layout is being built, and never consults the live context stack. A stack describes what is enabled at this instant; a controls screen asks what could ever collide, across every state the game can reach including states the player has not visited. Those are different questions, and answering the second from the first is the mistake that produces phantom conflicts in one direction and hidden ones in the other.
Groups declared with WithGroupOfActions name actions rather than action maps. There is nothing to resolve them against the contexts, so the derivation skips them and logs which groups it skipped. Name those in an explicit WithExclusiveGroups set if they need scoping.
An action belongs to exactly one group, and membership is tracked per map and action together. That distinction matters more than it sounds: an action name is only unique inside its map, and an asset where On Foot and Driving each declare an Interact is perfectly ordinary. Those are two actions, so the two map groups each take their own and nothing objects. A real double claim, one group naming a map and another naming an action inside it, is rejected instead, because it leaves the conflict scope ambiguous with no defensible answer.
The same distinction shapes how WithGroupOfActions reads its names. A bare name takes the action of that name from every map that declares one, since a group declared by action usually exists to gather across maps. Where two maps share a name and only one of them belongs in the group, write it as "MapID/ActionID" to take just that one. A name matching nothing is logged rather than thrown, so a typo shows up in the console instead of taking the screen down.
Save, load, and reset bindings
Problem. You want the player’s custom bindings to survive a relaunch and to support an “Undo my changes” button that reverts to whatever was last saved. You also want a “Reset All” button that takes everything back to the shipped defaults.
Solution. Persistence is three calls: SaveBindings(profileID = null) writes the override set; LoadBindings(profileID = null) reads it back and applies; ResetAllToDefaults() drops every override. The defaults in the InputActionAsset are never touched by the storage layer.
using Scylla.Input;
using UnityEngine;
public sealed class BindingsPersistencePanel : MonoBehaviour
{
/* Save the current profile. SaveBindings(null) is equivalent. */
public void OnSaveClicked()
{
ScyllaInput.Instance.BindingManager.SaveBindings();
}
/* Reload the current profile (an "undo my changes since last save" button).
* LoadBindings calls ResetAllToDefaults internally before applying the
* stored payload, so you always start from a clean slate. */
public void OnReloadClicked()
{
ScyllaInput.Instance.BindingManager.LoadBindings();
}
/* Reset every binding to its default. Does not auto-save; call
* SaveBindings afterwards to make the reset stick across relaunches. */
public void OnResetAllClicked()
{
var binding = ScyllaInput.Instance.BindingManager;
binding.ResetAllToDefaults();
binding.SaveBindings();
}
/* Reset one specific action. ResetToDefaults removes every override on
* that action without touching others. The map is required because an
* action name is only unique inside its map. */
public void OnResetActionClicked(string mapID, string actionID)
{
var binding = ScyllaInput.Instance.BindingManager;
binding.ResetToDefaults(mapID, actionID);
binding.SaveBindings();
}
/* Switch to a different profile and load its overrides. The two calls
* are deliberately separate: SetCurrentProfile only changes the active
* ID, LoadBindings reads the stored payload. */
public void OnSelectProfile(string profileID)
{
var binding = ScyllaInput.Instance.BindingManager;
binding.SetCurrentProfile(profileID);
if (binding.HasSavedBindings(profileID))
{
binding.LoadBindings();
}
else
{
binding.ResetAllToDefaults();
}
}
}
Switch storage backends per platform
Problem. You want players on desktop to be able to inspect and share their binding files, but you also want the game to work cleanly on console and mobile without touching the file system. The zero-config PlayerPrefsBindingStorage works everywhere, but the JSON alternative is dramatically better for desktop modding and community-shared control profiles.
Solution. Call SetStorage once at startup, gated on a platform #if block. Swapping the backend does not migrate existing data, so check HasSavedBindings after the swap and handle the empty-backend case.
using Scylla.Core;
using Scylla.Input;
using UnityEngine;
public sealed class StorageBackendSelector : MonoBehaviour
{
private void Start()
{
ScyllaCore.Instance.RunWhenReady(ConfigureStorage);
}
private void ConfigureStorage()
{
var binding = ScyllaInput.Instance.BindingManager;
#if UNITY_STANDALONE || UNITY_EDITOR
/* Desktop: prefer JSON files. Players can inspect, share, and edit
* the file at Application.persistentDataPath/Scylla/Input/.
* The default base path can be overridden via the constructor
* when a custom location is required (e.g. portable installs). */
binding.SetStorage(new JSONFileBindingStorage());
#else
/* Other platforms: stay on PlayerPrefs. Console and mobile builds
* typically do not benefit from JSON-on-disk and may forbid
* arbitrary file writes outside platform-specific APIs. */
binding.SetStorage(new PlayerPrefsBindingStorage());
#endif
/* Swapping the backend does not migrate existing saved data.
* If the new backend has no profile saved, reset to defaults
* (or load from a different source) before continuing. */
if (!binding.HasSavedBindings("default"))
{
binding.ResetAllToDefaults();
}
else
{
binding.LoadBindings("default");
}
}
}
Build a full options screen with InputRebindManager
Problem. A single “Rebind Jump” button is enough for some games. But if you are building a standard options screen with one row per action, one column per control scheme (keyboard, gamepad, alternate layout), conflict highlights, and a Reset All button, the single-binding API requires a lot of bookkeeping. You want a data model you can iterate over and drive directly from UI without tracking every action/index pair yourself.
Solution. InputRebindManager builds that data model for you. Construct it with the action manager and the binding manager, call Initialize(layout) with a ConfigLayout that declares your columns and groups, then walk the entries to instantiate UI rows. The manager owns the rebind lifecycle, the cooldown, and the conflict tracking; your UI layer only calls StartRebind, CancelRebind, and ResetAll.
using Scylla.Input;
using UnityEngine;
public sealed class InputConfigScreen : MonoBehaviour
{
private InputRebindManager _config;
private void Start()
{
Scylla.Core.ScyllaCore.Instance.RunWhenReady(InitializeConfigScreen);
}
private void InitializeConfigScreen()
{
var input = ScyllaInput.Instance;
/* Construct with the action manager and the binding manager. */
_config = new InputRebindManager(input.ActionManager, input.BindingManager);
/* Declare the screen: which control schemes get a column, which
* actions group under which heading, and which of those groups can
* never be live together. Column order matches declaration order.
* WithExclusiveGroupsFromContexts reads the conflict-exclusive tags
* already authored on the game's input contexts, so the exclusivity
* is stated once rather than restated here. */
var layout = ConfigLayout.Create()
.WithColumn("WASD", "Keyboard")
.WithColumn("Gamepad", "Gamepad", "<Gamepad>")
.WithColumn("IJKL", "Alternate")
.WithGroup("OnFoot", "On Foot", "On Foot")
.WithGroup("InCar", "In Car", "In Car")
.WithGroup("Menu", "Menu", "Pause Menu")
.WithExclusiveGroupsFromContexts(input.ContextManager)
.Build();
_config.Initialize(layout);
BuildUI();
}
private void BuildUI()
{
/* For each map, walk the entries and instantiate UI rows.
* ConfigActionEntry is one row; its Bindings list is one cell per
* scheme group. Each cell exposes DisplayText, IsRebindable,
* HasOverride, and the BindingIndex needed to start a rebind. */
foreach (var groupID in _config.GroupIDs)
{
/* GetGroupDisplayName(groupID) gives the heading text a table
* renders above its rows. */
var entries = _config.GetEntriesForGroup(groupID);
foreach (var entry in entries)
{
/* InstantiateRow is project-specific. The data model carries
* everything the UI needs; the project supplies the widget layer.
* See the ConfigScreen demo for a full reference implementation. */
InstantiateRow(entry);
}
}
}
/* IsInCooldown is true for 200 ms after a rebind completes. UI rebind
* buttons should check it before opening their mid-rebind state to
* prevent the pointer-up click that finished the rebind from
* immediately re-triggering another one. */
public void OnCellRebindClicked(ConfigActionEntry entry, ConfigBindingEntry bindingEntry)
{
if (_config.IsRebinding || _config.IsInCooldown) return;
if (!bindingEntry.IsRebindable || bindingEntry.BindingIndex < 0) return;
_config.StartRebind(entry.MapID, entry.ActionID, bindingEntry.BindingIndex, bindingEntry.SchemeGroup);
}
/* Reset all overrides in the data model, then re-save. */
public void OnResetAllClicked()
{
_config.ResetAll();
ScyllaInput.Instance.BindingManager.SaveBindings();
}
/* Let the manager auto-remap any colliding bindings to free keys,
* then save the resolved state. */
public void OnAutoResolveClicked()
{
var result = _config.AutoResolveConflicts();
if (result.ConflictsResolved > 0)
{
ScyllaInput.Instance.BindingManager.SaveBindings();
}
}
private void OnDestroy()
{
/* InputRebindManager is IDisposable; dispose explicitly so its
* subscriptions and the data model are released cleanly. */
_config?.Dispose();
}
private void InstantiateRow(ConfigActionEntry entry) { /* project-specific */ }
}
API Sketch
Two manager surfaces make up the API here, the low-level binding manager and the high-level rebind manager, along with the payload types that pass between them.
namespace Scylla.Input
{
/* Low-level: reached via ScyllaInput.Instance.BindingManager. */
public interface IInputBindingManager
{
bool IsInitialized { get; }
bool IsRebinding { get; }
InputRebindOperation CurrentRebindOperation { get; }
string CurrentProfileID { get; }
/* Auto-save after every successful rebind. Set from the configuration. */
bool AutoSaveOnRebind { get; set; }
/* Storage backend swap. The module installs the configured backend at
* startup; this overrides it. */
void SetStorage(IBindingStorage storage);
/* Rebind. Single active operation at a time. */
InputRebindOperation StartRebind(string mapID, string actionID, int bindingIndex = 0);
void CancelRebind();
void UpdateRebind();
/* Binding queries. */
string GetBindingPath (string mapID, string actionID, int bindingIndex = 0);
string GetBindingDisplayString (string mapID, string actionID, int bindingIndex = 0);
bool HasBindingOverride (string mapID, string actionID, int bindingIndex = 0);
/* Binding modification (programmatic; bypasses interactive rebind). */
void SetBindingOverride (string mapID, string actionID, int bindingIndex, string path);
void ClearBindingOverride (string mapID, string actionID, int bindingIndex);
void ResetToDefaults (string mapID, string actionID);
void ResetAllToDefaults ();
/* Conflict detection. excludeActionID excludes one action by name. */
IReadOnlyList<BindingConflict> GetConflicts(string path, string excludeActionID = null);
IReadOnlyList<BindingConflict> GetConflicts(string path, string excludeActionID,
in InputInteractionSignature queryInteractions);
bool HasConflict (string path, string excludeActionID = null);
/* Persistence. profileID = null falls back to CurrentProfileID. */
void SaveBindings (string profileID = null);
void LoadBindings (string profileID = null);
bool HasSavedBindings(string profileID = null);
void DeleteProfile (string profileID);
IReadOnlyList<string> GetAllProfiles();
void SetCurrentProfile(string profileID);
/* JSON round-trip. Same payload shape the storage backends save. */
string ExportBindingsAsJSON();
void ImportBindingsFromJSON(string json);
/* C# events on the manager. SEX events also published (see below). */
event Action<InputRebindOperation> OnRebindStarted;
event Action<InputRebindOperation, bool, string> OnRebindCompleted; /* (operation, success, newPath) */
event Action<string> OnBindingsLoaded; /* profileID */
event Action<string> OnBindingsSaved; /* profileID */
}
/* Declares a config screen's columns, row groups, and exclusive sets. */
public sealed class ConfigLayout
{
public const string UNGROUPED_ID = "__ungrouped";
public IReadOnlyList<ConfigSchemeGroup> Columns { get; }
public IReadOnlyList<ConfigActionGroup> Groups { get; }
public IReadOnlyList<ConfigGroupExclusion> Exclusions { get; }
public static Builder Create();
public bool AreGroupsExclusive(string groupA, string groupB);
public sealed class Builder
{
public Builder WithColumn(string groupName);
public Builder WithColumn(string groupName, string displayName);
public Builder WithColumn(string groupName, string displayName, string deviceFilter);
public Builder WithGroup(string groupID, string displayName, params string[] actionMapIDs);
public Builder WithGroupOfActions(string groupID, string displayName, params string[] actionIDs);
/* Exclusivity, declared directly or read from the input contexts. */
public Builder WithExclusiveGroups(params string[] groupIDs);
public Builder WithExclusiveGroupsFromContexts(IInputContextManager contexts);
public ConfigLayout Build();
}
}
/* High-level: instantiated by user code; one per config screen. */
public class InputRebindManager : IDisposable
{
public InputRebindManager(
IInputActionManager actionManager,
IInputBindingManager bindingManager);
public IReadOnlyList<ConfigSchemeGroup> SchemeGroups { get; }
public IReadOnlyList<string> MapIDs { get; }
public IReadOnlyList<string> GroupIDs { get; }
public ConfigLayout Layout { get; }
public bool IsInitialized { get; }
public bool IsRebinding { get; }
public bool IsInCooldown { get; } /* true for 200 ms after rebind completion */
public string ClearBindingPath { get; set; } /* default "<Keyboard>/delete" */
public int ActionMapCount { get; }
public int ActionCount { get; } /* counts only the first scheme group */
public int BindingCountTotal { get; }
public int ConflictCount { get; }
public void Initialize(ConfigLayout layout);
public void Initialize(ConfigLayout layout,
IInputRebindabilityProvider rebindability,
bool schemesAreExclusive = false);
/* Data model accessors. */
public IReadOnlyList<ConfigActionEntry> GetEntriesForMap(string mapID);
public IReadOnlyList<ConfigActionEntry> GetEntriesForGroup(string groupID);
public string GetGroupDisplayName(string groupID);
public IReadOnlyList<ConfigActionEntry> GetAllEntries();
public IReadOnlyList<ConfigActionEntry> GetEntriesForAction(string mapID, string actionID);
/* Rebindability, from the provider passed to Initialize. */
public InputRebindability GetRebindability(string mapID, string actionID);
public bool IsRebindable (string mapID, string actionID);
/* Rebind lifecycle. */
public bool StartRebind(ConfigBindingEntry binding);
public bool StartRebind(string mapID, string actionID, int bindingIndex, string schemeGroup);
public void CancelRebind();
/* Batch operations. */
public void ResetAll();
public void ResetAction(ConfigActionEntry entry);
public void ResetAction(string mapID, string actionID);
public void ClearAll();
public void RefreshAll();
public void RefreshAction(ConfigActionEntry entry);
public void RefreshAction(string mapID, string actionID);
public void SaveBindings(string profileID = null);
public void LoadBindings(string profileID = null);
/* Conflict detection. */
public IReadOnlyList<ConfigConflict> GetConflictsForAction(string mapID, string actionID);
public IReadOnlyList<ConfigConflict> GetConflictsForEntry(ConfigActionEntry entry);
public IReadOnlyList<ConfigConflict> GetAllConflicts();
public bool HasConflicts(ConfigActionEntry entry);
public bool HasConflicts(string mapID, string actionID);
public AutoResolveResult AutoResolveConflicts();
public void Dispose();
}
/* Single rebind session, owned by IInputBindingManager. */
public class InputRebindOperation : IDisposable
{
public bool IsActive { get; }
public bool WasSuccessful { get; }
public bool WasCancelled { get; }
public string NewPath { get; }
public string OriginalPath { get; }
public string MapID { get; }
public string ActionID { get; }
public int BindingIndex { get; }
/* Fluent configuration (call before or after Start; takes effect immediately). */
public InputRebindOperation WithTimeout(float seconds); /* default: configured Timeout */
public InputRebindOperation WithCancelingThrough(string path); /* default: configured DefaultCancelPath */
public InputRebindOperation WithControlFilter(string prefix);
public InputRebindOperation WithExpectedControlType(string type);
public InputRebindOperation WithExcludedPath(string path);
public InputRebindOperation WithModifierSupport(bool enabled);
public InputRebindOperation WithDeltaPolling(bool enabled);
public void Start();
public void Cancel();
public event Action<bool, string> OnComplete; /* (success, newPath) */
public event Action<IReadOnlyList<BindingConflict>> OnConflictDetected;
public event Action OnCancelled;
}
/* Immutable record returned by IInputBindingManager.GetConflicts. */
public class BindingConflict
{
public string ConflictingActionID { get; }
public string ConflictingMapID { get; }
public int ConflictingBindingIndex { get; }
public string ConflictingPath { get; }
public string DisplayString { get; }
}
/* Conflict record returned by InputRebindManager's conflict detection. */
public class ConfigConflict
{
public string MapID { get; }
public string ActionID { get; }
public string ConflictingActionID { get; }
public string ConflictingMapID { get; }
public string SchemeGroup { get; }
public string SharedPath { get; }
public string DisplayText { get; }
}
/* Storage backend interface and the two shipped implementations. */
public interface IBindingStorage
{
string StorageID { get; }
void SaveBindings(string profileID, string jsonData);
string LoadBindings(string profileID);
bool DeleteBindings(string profileID);
bool HasBindings(string profileID);
IReadOnlyList<string> GetAllProfiles();
}
public class PlayerPrefsBindingStorage : IBindingStorage { /* zero config; selected by StorageType PlayerPrefs */ }
public class JSONFileBindingStorage : IBindingStorage /* the configured default */
{
public string BasePath { get; } /* defaults to {persistentDataPath}/Scylla/Input */
public JSONFileBindingStorage();
public JSONFileBindingStorage(string basePath);
}
/* Bus event payloads. */
public class InputRebindStartedEvent : Scylla.Core.Events.ScyllaEvent
{
public string MapID { get; set; }
public string ActionID { get; set; }
public int BindingIndex { get; set; }
public string CurrentPath { get; set; }
}
public class InputRebindCompletedEvent : Scylla.Core.Events.ScyllaEvent
{
public string MapID { get; set; }
public string ActionID { get; set; }
public int BindingIndex { get; set; }
public string OldPath { get; set; }
public string NewPath { get; set; }
public bool Success { get; set; }
}
public class InputRebindCancelledEvent : Scylla.Core.Events.ScyllaEvent
{
public string MapID { get; set; }
public string ActionID { get; set; }
public int BindingIndex { get; set; }
}
public class InputBindingsSavedEvent : Scylla.Core.Events.ScyllaEvent { public string ProfileID { get; set; } }
public class InputBindingsLoadedEvent : Scylla.Core.Events.ScyllaEvent { public string ProfileID { get; set; } }
/* Published by InputRebindManager, the managed layer. Richer than the low-level
pair above: they carry the map, the scheme group and the device type, which is
what an options screen needs to update one cell rather than rebuilding. */
public class ManagedRebindStartedEvent : Scylla.Core.Events.ScyllaEvent
{
public string MapID { get; set; }
public string ActionID { get; set; }
public int BindingIndex { get; set; }
public string SchemeGroup { get; set; }
public string CurrentPath { get; set; }
public InputDeviceType DeviceType { get; set; }
}
public class ManagedRebindCompletedEvent : Scylla.Core.Events.ScyllaEvent
{
public string MapID { get; set; }
public string ActionID { get; set; }
public int BindingIndex { get; set; }
public string NewPath { get; set; }
public ScyllaModifier Modifiers { get; set; }
public bool Success { get; set; }
}
public class ManagedRebindCancelledEvent : Scylla.Core.Events.ScyllaEvent
{
public string MapID { get; set; }
public string ActionID { get; set; }
public int BindingIndex { get; set; }
}
public class ManagedRebindDataRefreshedEvent : Scylla.Core.Events.ScyllaEvent
{
public int ActionCount { get; set; }
public int MapCount { get; set; }
}
}
See the API reference for full signatures, remarks, and exception contracts on every member.
Storage backends
The table below compares the two shipped backends and their trade-offs.
| Backend | Where data lives | Player can inspect / edit | Cross-platform | Best fit |
|---|---|---|---|---|
PlayerPrefsBindingStorage | Unity PlayerPrefs registry (platform-defined location, e.g. Windows registry, plist on macOS) | No (opaque registry entries) | Yes | Console, mobile, and any project where players are not expected to hand-edit the binding file. |
JSONFileBindingStorage | Application.persistentDataPath/Scylla/Input/bindings_{profileID}.json (configurable base path) | Yes (plain JSON file) | Yes (desktop) | Default. Desktop. Modding support. Cloud-saved profiles where the JSON gets shared between machines. |
Custom IBindingStorage | Wherever the implementation puts it | Depends | Depends | Cloud sync, save-game integration, platform-specific storage APIs. |
The backend comes from the configuration’s Binding Storage settings, which default to JSON files. InputBindingManager.Initialize falls back to PlayerPrefsBindingStorage when nothing has been installed, but a module-hosted project never sees that fallback: ScyllaInput installs the configured backend immediately afterwards. Swapping later does not migrate; existing saved data stays in the previous backend until manually exported and re-imported.
Best Practices
- Always call
SaveBindingsafter a successful rebind. The override is applied to the Unity action immediately, but it does not survive a relaunch unless persisted. The save call is cheap (one storage write per call); inside anOnCompletehandler is the right place for it. - Check
GetConflicts(candidatePath, excludeActionID: rebindActionID)before committing a programmaticSetBindingOverride. The interactive rebind operation does this automatically; programmatic code has to do it explicitly or risk double-binding. - Give
BindingManager.Initializethe context manager if you host a binding manager yourself and your project tags its contexts as mutually exclusive. The module already passes its own. Without it, conflict detection cannot skip bindings from contexts that are never live together, and you will see phantom conflicts when rebinding shared action names (e.g.Cancel,Confirm). - Set the backend in the configuration asset, and reach for
SetStorageonly when the choice is per-platform. Binding Storage settings cover storage type, JSON folder, default profile, and the auto-load and auto-save switches without any code. The platform#ifblock in the storage-switching recipe is for the case configuration cannot express: mobile and console platforms restrict direct file system writes and suit the PlayerPrefs path, while desktop benefits from the inspect-and-edit JSON path. - Construct one
InputRebindManagerper config screen; dispose on close. It is per-screen state; instantiating it once at startup ties the data model to a single set of scheme columns and leaks subscriptions when alternate screens want different columns. - Check
IsInCooldownbefore opening a cell’s mid-rebind UI state. The 200 ms cooldown after a rebind completion prevents the mouse-button press that finished the rebind from immediately re-triggering another via Unity UI’s pointer-upOnClick. UI code that skips the check leaves cells stuck in the “press a key” state until the player clicks them again. - Subscribe to
InputBindingsLoadedEventandInputBindingsSavedEventfor HUD prompt refreshes. Button-prompt resolvers need to re-cache labels after a load (the bindings just changed); a small handler that bumps a cache version is the cleanest hook. - Use
ExportBindingsAsJSON/ImportBindingsFromJSONfor cross-device profile transfer. The same JSON payload survives a copy-paste between machines, a cloud-sync round trip, or a community-shared “tournament keymap”. Storage backends are the persistence path; the JSON pair is the portable path. - Reset to defaults with
ResetAllToDefaultsand thenSaveBindings. The reset only clears in-memory overrides; without the save, the next launch loads the player’s old saved overrides and the reset appears to not stick. - Treat
InputActionAssetdefaults as the authoritative bindings. Override-only persistence means a patch can change a default keybind without invalidating saved profiles. Updating a default in the asset is the right shape for “we shipped Crouch on C and playtesters complained; ship Crouch on LeftCtrl in the patch” – players who never rebound C keep getting Crouch on the new default, players who rebound get to keep their custom. - Use
ConfigSchemeGroup.DeviceFilterto restrict which device a column accepts during rebind. Passing"<Gamepad>"as the device filter for the Gamepad column ensures a keyboard key press cannot accidentally land in a gamepad binding cell. Without the filter, any input device is eligible during the capture window.
Pitfalls
Related
- 01 Scylla Input
- 03 Input – Actions and Bindings
- 04 Input – Contexts
- API reference:
Scylla.Input.IInputBindingManager - API reference:
Scylla.Input.InputRebindOperation - API reference:
Scylla.Input.BindingConflict - API reference:
Scylla.Input.IBindingStorage - API reference:
Scylla.Input.JSONFileBindingStorage - API reference:
Scylla.Input.PlayerPrefsBindingStorage - API reference:
Scylla.Input.InputRebindManager - API reference:
Scylla.Input.InputRebindStartedEvent - API reference:
Scylla.Input.InputRebindCompletedEvent - API reference:
Scylla.Input.InputRebindCancelledEvent - API reference:
Scylla.Input.InputBindingsSavedEvent - API reference:
Scylla.Input.InputBindingsLoadedEvent