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-only — attach() 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.
Mental model
Section titled “Mental model”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:
- Handler — your
Handler(state, input)runs first, wrapped inxpcall. A throwing handler warns with a traceback and does not suppress the next step. - Bus publish —
Kernel.Bus:publish("Input." .. name, state, input). - 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.
Bind actions
Section titled “Bind actions”-- Client Bootstraplocal 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 deviceInput:bindAction("Cast", { Keyboard = { Enum.KeyCode.E, Enum.KeyCode.F }, Gamepad = Enum.KeyCode.ButtonX,})
-- Mobile: TouchButton = true creates the stock CAS on-screen buttonInput: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.
Listen without owning the binding
Section titled “Listen without owning the binding”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() endend)
-- Or the whole family:Kernel.Bus:subscribe("Input.*", function(topic, state) Analytics:noteInput(topic, state)end)Remap at runtime
Section titled “Remap at runtime”-- Settings menu: replace the keyboard set; gamepad and handler are untouchedInput:rebind("Sprint", "Keyboard", Enum.KeyCode.LeftControl)
-- Arrays work the same wayInput:rebind("Cast", "Keyboard", { Enum.KeyCode.Q })
-- nil clears a device entirely (keyboard-only players can drop gamepad binds)Input:rebind("Cast", "Gamepad", nil)Persist keymaps across rejoins
Section titled “Persist keymaps across rejoins”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 rejoinslocal 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 Keysend
-- 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 correctionsPrefs:onChanged(function(key, value) if key == "DashKeys" and type(value) == "table" then Input:rebind("Dash", "Keyboard", toKeyCodes(value)) endend)
-- Settings UI picked a new key: rebind, snapshot, persistlocal 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 })endThe matching server schema (see Player settings) is the allowlist — clients cannot write anything else:
DashKeys = { Kind = "strings", MaxItems = 2, MaxLength = 20, Default = { "Q" } },Disable an action
Section titled “Disable an action”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 startedInput:bindAction("Cast", CastBinding) -- cutscene over; keep the binding table aroundunbindAction forgets the binding, so keep the ActionBinding table yourself if you intend to restore it.
API reference
Section titled “API reference”Constructor
Section titled “Constructor”| 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 |
Methods
Section titled “Methods”| 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 |
ActionBinding
Section titled “ActionBinding”| 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 |
Bus topics
Section titled “Bus topics”| 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 |