InputDriver
Binds named actions over ContextActionService so one binding covers keyboard, gamepad and touch, and publishes every action event on the kernel Bus.
- Realm
- Client
- Require
ReplicatedStorage.ChloeKernel .InputDriver - Since
- v0.1.0
- Source
- InputDriver.luau
InputDriver turns device input into named actions. One binding lists the keyboard keys, the gamepad buttons, an optional
touch button and a handler, and the driver owns the ContextActionService (CAS) calls. Every action event also
publishes on the kernel Bus as Input.<Action>, so a system that cares about “the player pressed
Sprint” subscribes to the topic and never learns which key it was. Bindings
change at runtime and topics do not, so remapping touches one place. Keymaps persist through
Player settings, and MoveKit triggers abilities from these topics.
InputDriver is client-only: attach() errors on the server. Input is presentation. The client turns actions into
intents and the server validates those intents as if any client could have sent them.
Mental model
Section titled “Mental model”bindAction(name, binding) collects the keyboard and gamepad keys into one list and registers one CAS action named
CK_<name>. The CK_ prefix keeps the driver’s actions apart from any CAS binds the game makes directly. When a bound
input changes state, the CAS callback does three things in this order:
- Publishes
Input.<name>on the Bus with(state, input). - Starts
Handler(state, input)on its own thread withtask.spawn, insidexpcall. A handler error is logged throughLogger.scope("InputDriver")with a traceback and goes no further. - Returns
Enum.ContextActionResult.Pass, orSinkwhen the binding setsSink = true.
The handler gets its own thread because CAS sinks the input when its callback yields. A handler that invokes a Request
channel or waits on a tween would otherwise swallow the key it was bound to. task.spawn runs the handler immediately,
so a handler that never yields still finishes before the callback returns; one that yields finishes later, after CAS has
already read the result. Bus subscribers always hear the event first and never wait on the handler.
Keyboard and gamepad keys share one CAS action, so both device families work at once without a mode switch. CAS
dispatches whichever input the player used, and the handler can branch on input.UserInputType if it needs to.
Priority and sinking
Section titled “Priority and sinking”Without Priority the action binds with BindAction at the CAS default. With Priority it binds with
BindActionAtPriority, and a higher priority runs first whatever the bind order. Any integer works;
Enum.ContextActionPriority gives Low (1000), Medium (2000) and High (3000).
By default an action passes its input on to lower bindings and to the default player scripts, so binding Space to an
action does not stop the character jumping. Sink = true stops the input at this action. A high-priority sinking action
is how a modal or a block stance takes a key away from everything below it.
Touch buttons
Section titled “Touch buttons”TouchButton = true asks CAS for its stock on-screen button. Title, Image and Position are applied after the bind
with SetTitle, SetImage and SetPosition on the prefixed action name, and applied again after every rebind. They
are ignored when TouchButton is not set. CAS creates at most 7 touch buttons; binding an eighth logs a warning because
it may not show. Past that, build your own GuiButtons.
Rebinding
Section titled “Rebinding”rebind(name, device, keys) unbinds the CAS action, replaces that device’s keys in the stored binding and binds again.
Everything else in the binding survives: the handler, the other device’s keys, the touch button and its face, Priority
and Sink. A settings menu can remap one device without rebuilding the binding.
A key held down while its action is rebound or taken by a higher-priority binding receives Enum.UserInputState.Cancel
instead of End. Hold-style handlers treat Cancel as release.
Preferred input
Section titled “Preferred input”When attach receives a kernel, it connects to UserInputService:GetPropertyChangedSignal("PreferredInput") and
publishes Input.PreferredChanged(preferred) on every change. driver:preferred() reads the current
Enum.PreferredInput (KeyboardAndMouse, Gamepad, Touch or MicroGamepad). Switch prompts and button glyphs on
this instead of guessing from the last input event. destroy() disconnects the listener.
Key names and labels
Section titled “Key names and labels”getBindings() returns key names as strings so the snapshot serializes into a settings profile. InputDriver.parseKeys
turns stored names back into Enum.KeyCodes through Enum.KeyCode:FromName. It accepts one name or a list, and drops
unknown names, non-string entries and deprecated keys (World0 to World95, MouseBackButton, MouseNoButton,
MouseX, MouseY) so a stale or tampered setting never errors.
KeyCodes are physical QWERTY positions: Enum.KeyCode.Q is the key labelled “A” on an AZERTY keyboard.
InputDriver.label(key, abbreviated?) returns what to show the player for their own layout through
UserInputService:GetStringForKeyCode, with Enum.KeyCodeStringFormat.Abbreviated when abbreviated is true (short
forms such as “LCtrl” where one exists).
Bind actions in a controller
Section titled “Bind actions in a controller”local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
return Registry.define({ Cast = { Kind = "Intent", Schema = { "NumberU8" }, RateLimit = 4, Validate = { [1] = { Range = { 1, 3 } } } }, Block = { Kind = "Intent", Schema = { "Boolean8" }, RateLimit = 10, Validate = { [1] = { OneOf = { true, false } } } },})--!strict-- CombatController: binds the combat actions and sends their intents.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)
local CombatController = { Name = "CombatController",}
function CombatController.init(self: any, kernel: any) self.Kernel = kernelend
function CombatController.start(self: any) local Input = InputDriver.attach(self.Kernel) self.Input = Input
Input:bindAction("Firebolt", { Keyboard = { Enum.KeyCode.Q, Enum.KeyCode.One }, Gamepad = Enum.KeyCode.ButtonX, TouchButton = true, Title = "Bolt", Handler = function(state) if state == Enum.UserInputState.Begin then self.Net.Cast.fire(1) end end, })
-- High priority and Sink: while held, C and L2 reach nothing below this action Input:bindAction("Block", { Keyboard = Enum.KeyCode.C, Gamepad = Enum.KeyCode.ButtonL2, TouchButton = true, Title = "Block", Position = UDim2.new(1, -90, 1, -150), Priority = Enum.ContextActionPriority.High.Value, Sink = true, Handler = function(state) if state == Enum.UserInputState.Begin then self.Net.Block.fire(true) elseif state == Enum.UserInputState.End or state == Enum.UserInputState.Cancel then self.Net.Block.fire(false) end end, })end
function CombatController.stop(self: any) if self.Input then self.Input:destroy() self.Input = nil endend
return CombatControllerListen without owning the binding
Section titled “Listen without owning the binding”Other systems subscribe to the action topic instead of importing the driver. Bus handlers receive the topic first:
self.Connection = self.Kernel.Bus:subscribe("Input.Block", function(_topic, state: Enum.UserInputState) Crosshair.Visible = state ~= Enum.UserInputState.Beginend)Disconnect the connection in the owner’s stop. A wildcard Input.* subscription hears every action and also
Input.PreferredChanged, whose first argument is an Enum.PreferredInput, not a UserInputState.
Switch prompts on the preferred device
Section titled “Switch prompts on the preferred device”local function showPrompts(preferred: Enum.PreferredInput) KeyboardPrompts.Visible = preferred == Enum.PreferredInput.KeyboardAndMouse GamepadPrompts.Visible = preferred == Enum.PreferredInput.Gamepad TouchHint.Visible = preferred == Enum.PreferredInput.Touchend
showPrompts(Input:preferred())self.PreferredConnection = self.Kernel.Bus:subscribe("Input.PreferredChanged", function(_topic, preferred) showPrompts(preferred)end)
-- Show the layout-correct key name, "A" on AZERTY for KeyCode.QBoltKeyLabel.Text = InputDriver.label(Enum.KeyCode.Q)Remap and persist keymaps
Section titled “Remap and persist keymaps”The server schema in Player settings is the allowlist for what the client may store:
DashKeys = { Kind = "strings", MaxItems = 2, MaxLength = 20, Default = { "Q" } },The client applies the stored value at boot, follows confirmed changes and writes new picks back:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)local SettingsClient = require(ReplicatedStorage.ChloeKernel.Net.SettingsClient)
local Input = InputDriver.attach(kernel)local Prefs = SettingsClient.new(NetClient.new())
Input:bindAction("Dash", { Keyboard = Enum.KeyCode.Q, Handler = onDash })
local function applyStored(value: any) local Keys = InputDriver.parseKeys(value) if #Keys > 0 then Input:rebind("Dash", "Keyboard", Keys) endend
Prefs:fetch()applyStored(Prefs:get("DashKeys"))
-- Confirmed changes, including server corrections and the profile-load syncPrefs:onChanged(function(key, value) if key == "DashKeys" then applyStored(value) endend)
-- The settings UI picked a new keylocal function pickDashKey(key: Enum.KeyCode) Input:rebind("Dash", "Keyboard", key) local Names = Input:getBindings().Dash.Keyboard Prefs:set("DashKeys", if type(Names) == "table" then Names else { Names })endDisable an action for a while
Section titled “Disable an action for a while”The driver has no enable switch. An action either has a CAS bind or it does not. Keep the binding table and bind it again:
Input:unbindAction("Firebolt") -- cutscene startsInput:bindAction("Firebolt", FireboltBinding) -- cutscene endsunbindAction forgets the binding, so the caller keeps the ActionBinding table if it plans to restore it.
API reference
Section titled “API reference”Constructor and helpers
Section titled “Constructor and helpers”| Member | Description |
|---|---|
InputDriver.attach(kernel, options?) -> InputDriver |
Client-only; errors on the server. options = { ContextActionService?, UserInputService?, SkipRoleCheck? }, all spec seams. With a kernel, publishes Input.PreferredChanged. |
InputDriver.new |
Deprecated alias of attach. |
InputDriver.parseKeys(names) -> { Enum.KeyCode } |
Turns a stored name or list of names into KeyCodes. Drops unknown, non-string and deprecated names. Never errors. |
InputDriver.label(key, abbreviated?) -> string |
The key’s label on the player’s keyboard layout, from UserInputService:GetStringForKeyCode. Client only. |
Methods
Section titled “Methods”| Member | Description |
|---|---|
driver:bindAction(name, binding) |
Registers the action as CK_<name>. Errors with action "<name>" is already bound on a duplicate name. Warns past 7 touch buttons. |
driver:rebind(name, device, keys?) |
device is "Keyboard" or "Gamepad". Replaces that device’s keys (one Enum.KeyCode, a list, or nil to clear) and binds again, keeping every other field. Errors on an unknown action. |
driver:unbindAction(name) |
Unbinds from CAS and forgets the binding. Does nothing for an unknown name. |
driver:preferred() -> Enum.PreferredInput |
The player’s primary input device right now. |
driver:getBindings() -> snapshot |
{ [action] = { Keyboard?, Gamepad?, TouchButton? } } with key names (a string or a list, matching the binding’s shape). |
driver:destroy() |
Unbinds every action and disconnects the preferred-input listener. |
ActionBinding
Section titled “ActionBinding”| Member | Description |
|---|---|
Keyboard: Enum.KeyCode | { Enum.KeyCode }? |
Keyboard keys for the action. |
Gamepad: Enum.KeyCode | { Enum.KeyCode }? |
Gamepad buttons for the action. |
TouchButton: boolean? |
true creates the CAS on-screen button. |
Title: string? |
Touch button text. Ignored without TouchButton. |
Image: string? |
Touch button image id. Ignored without TouchButton. |
Position: UDim2? |
Touch button position. Ignored without TouchButton. |
Priority: number? |
CAS stack priority. Higher runs first. nil binds at the CAS default. |
Sink: boolean? |
true stops the input at this action. Default passes it on. |
Handler: ((state, input?) -> ())? |
Runs on its own thread after the Bus publish, so it may yield. Errors are logged with a traceback. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Input.<Action> |
state: Enum.UserInputState, input: InputObject? |
On the client, every time a bound input changes state, before the handler starts. |
Input.PreferredChanged |
preferred: Enum.PreferredInput |
On the client, when UserInputService.PreferredInput changes. Only when attach received a kernel. |