Skip to content

InputDriver

InputDriver turns raw device input into named actions. One binding declares the keyboard keys, the gamepad buttons, an optional touch button, and a handler; the driver owns the ContextActionService wiring underneath. The defining decision: every action event also publishes on the kernel Bus as Input.<Action>, so any system can react to “the player pressed Sprint” without owning — or even knowing — the physical binding. Bindings change at runtime; topics never do. That split is what makes player remapping cheap.

InputDriver is client-onlyattach() errors on the server. Input is presentation: the client sends intents built from these actions, and the server validates those intents without ever trusting where they came from.

bindAction(name, binding) collects every key from the binding — keyboard and gamepad together — and registers one ContextActionService action named CK_<name>. The CK_ prefix keeps the driver’s actions from colliding with any CAS binds your game makes directly. When any of the bound inputs fires, the CAS callback runs a fixed pipeline:

  1. Handler — your Handler(state, input) runs first, wrapped in xpcall. A throwing handler warns with a traceback and does not suppress the next step.
  2. Bus publishKernel.Bus:publish("Input." .. name, state, input).
  3. Pass — the callback returns Enum.ContextActionResult.Pass, so the same physical input still reaches other CAS binds and the default controls. InputDriver never sinks input.

Because keyboard and gamepad keys ride the same CAS action, both device families coexist in one binding with no mode switch — CAS dispatches whichever physical input the player actually used, and the handler can branch on input.UserInputType when it cares.

rebind(name, device, keys) is an unbind-and-rebind of the same CAS action with the binding’s key set for that device replaced. The Handler, the TouchButton flag, and the other device’s keys survive untouched — which is exactly what a settings menu needs.

There are no context stacks in the driver itself; CAS’s own binding stack applies as usual since the driver passes rather than sinks.

-- Client Bootstrap
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Kernel = require(ReplicatedStorage.ChloeKernel).boot()
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)
local Input = InputDriver.attach(Kernel)
Input:bindAction("Sprint", {
Keyboard = Enum.KeyCode.LeftShift,
Gamepad = Enum.KeyCode.ButtonL3,
Handler = function(state)
setSprinting(state == Enum.UserInputState.Begin)
end,
})
-- One action, several physical inputs: pass an array per device
Input:bindAction("Cast", {
Keyboard = { Enum.KeyCode.E, Enum.KeyCode.F },
Gamepad = Enum.KeyCode.ButtonX,
})
-- Mobile: TouchButton = true creates the stock CAS on-screen button
Input:bindAction("Jump", {
Keyboard = Enum.KeyCode.Space,
TouchButton = true,
})

Enum.UserInputState.Begin and End arrive on press and release; Cancel arrives when CAS revokes the input mid-press. Handle all three — treating anything that is not Begin as release is the robust pattern.

Systems subscribe to the action topic instead of importing the driver. Bus handlers receive (topic, ...):

Kernel.Bus:subscribe("Input.Sprint", function(_, state: Enum.UserInputState, input: InputObject?)
if state == Enum.UserInputState.Begin then
Camera:zoomOut()
end
end)
-- Or the whole family:
Kernel.Bus:subscribe("Input.*", function(topic, state)
Analytics:noteInput(topic, state)
end)
-- Settings menu: replace the keyboard set; gamepad and handler are untouched
Input:rebind("Sprint", "Keyboard", Enum.KeyCode.LeftControl)
-- Arrays work the same way
Input:rebind("Cast", "Keyboard", { Enum.KeyCode.Q })
-- nil clears a device entirely (keyboard-only players can drop gamepad binds)
Input:rebind("Cast", "Gamepad", nil)

getBindings() returns a plain-data snapshot — key names as strings, mirroring the single-or-array shape of each binding — built for settings UIs and for saving through the validated Settings service:

-- Client — keymaps that survive rejoins
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local SettingsClient = require(ReplicatedStorage.ChloeKernel.Net.SettingsClient)
local Net = NetClient.new()
local Prefs = SettingsClient.new(Net)
Prefs:fetch()
Input:bindAction("Dash", { Keyboard = Enum.KeyCode.Q, Handler = onDash })
local function toKeyCodes(names: { string }): { Enum.KeyCode }
local Keys = {}
for _, Name in names do
-- Indexing Enum.KeyCode with an unknown name throws; stored data is
-- validated server-side, but a schema change can strand old names
local Ok, Key = pcall(function()
return (Enum.KeyCode :: any)[Name]
end)
if Ok then
table.insert(Keys, Key)
end
end
return Keys
end
-- Boot: apply the stored keymap (the schema default covers first joins)
local Stored = Prefs:get("DashKeys")
if type(Stored) == "table" and #Stored > 0 then
Input:rebind("Dash", "Keyboard", toKeyCodes(Stored))
end
-- React to confirmed changes — including server-side corrections
Prefs:onChanged(function(key, value)
if key == "DashKeys" and type(value) == "table" then
Input:rebind("Dash", "Keyboard", toKeyCodes(value))
end
end)
-- Settings UI picked a new key: rebind, snapshot, persist
local function applyDashKey(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 matching server schema (see Player settings) is the allowlist — clients cannot write anything else:

DashKeys = { Kind = "strings", MaxItems = 2, MaxLength = 20, Default = { "Q" } },

There is no separate enable/disable switch — an action either has a CAS bind or it doesn’t. Unbind to disable, bind again to re-enable:

Input:unbindAction("Cast") -- cutscene started
Input:bindAction("Cast", CastBinding) -- cutscene over; keep the binding table around

unbindAction forgets the binding, so keep the ActionBinding table yourself if you intend to restore it.

Member Description
InputDriver.attach(kernel, options?) → InputDriver Client-only; errors on the server. options = { ContextActionService?, SkipRoleCheck? } — both are spec seams (inject a fake CAS, skip the role check in server-run tests)
InputDriver.new Deprecated alias of attach. Kernel-integrating modules use attach(); new is reserved for plain objects
Member Description
driver:bindAction(name, binding) Registers the action as CK_<name> over CAS. Errors if name is already bound
driver:rebind(name, "Keyboard" | "Gamepad", keys?) Replaces that device’s key set (single Enum.KeyCode, array, or nil to clear). Unbinds and rebinds the CAS action; handler and touch button survive. Errors on unknown actions
driver:unbindAction(name) Unbinds from CAS and forgets the binding. No-op if unbound
driver:getBindings() → snapshot { [action] = { Keyboard?, Gamepad?, TouchButton? } } with key names (string or array of strings, mirroring the binding’s shape)
driver:destroy() Unbinds every action and clears the table
Field Type 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 touch button
Handler ((state: Enum.UserInputState, input: InputObject?) -> ())? Runs before the bus publish; errors are caught and warned
Topic Payload Fired
Input.<Action> state: Enum.UserInputState, input: InputObject? Client-side, every time a bound input changes state — after the handler, even if the handler threw