Skip to content

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.

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:

  1. Publishes Input.<name> on the Bus with (state, input).
  2. Starts Handler(state, input) on its own thread with task.spawn, inside xpcall. A handler error is logged through Logger.scope("InputDriver") with a traceback and goes no further.
  3. Returns Enum.ContextActionResult.Pass, or Sink when the binding sets Sink = 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.

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.

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.

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.

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.

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

src/Shared/Game/Channels.luau
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 } } } },
})
src/Client/Game/Controllers/CombatController.luau
--!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 = kernel
end
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
end
end
return CombatController

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.Begin
end)

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.

local function showPrompts(preferred: Enum.PreferredInput)
KeyboardPrompts.Visible = preferred == Enum.PreferredInput.KeyboardAndMouse
GamepadPrompts.Visible = preferred == Enum.PreferredInput.Gamepad
TouchHint.Visible = preferred == Enum.PreferredInput.Touch
end
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.Q
BoltKeyLabel.Text = InputDriver.label(Enum.KeyCode.Q)

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)
end
end
Prefs:fetch()
applyStored(Prefs:get("DashKeys"))
-- Confirmed changes, including server corrections and the profile-load sync
Prefs:onChanged(function(key, value)
if key == "DashKeys" then
applyStored(value)
end
end)
-- The settings UI picked a new key
local 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 })
end

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 starts
Input:bindAction("Firebolt", FireboltBinding) -- cutscene ends

unbindAction forgets the binding, so the caller keeps the ActionBinding table if it plans to restore it.

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

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.