inputBinding assetType
One control in a scheme — jump, move, boost — expressed as an asset. A <name>.inputBinding/ folder whose init.luau returns the binding record, living inside a .inputMap/ parent the same way a .tool…
Containment is what records the reference, so a scene that carries its map
carries every control the map declares. A scene pulled into another world
cannot arrive somewhere boost binds to nothing.
-- racer.inputMap/boost.inputBinding/init.luau
local B = require("@builtin::modules.zinput.bindings")
return {
label = "Boost",
kind = "button",
kbm = { B.key("ShiftLeft") },
gamepad = { B.padButton("left_shoulder") },
touch = { B.touchButton({ zone = "right-lower" }) },
}
Every device class, always
kbm, gamepad and touch are all required. A control bound on one device
and not another is how a world ships playable on a desktop and dead on a
phone, so a record missing a class is refused at activation, naming the
binding and the class it lacks.
Setting a class to false suppresses it deliberately — a text-entry control
with no touch counterpart says so, and says it in the record where a reader
sees it.
The cost of that rule is paid at authoring time rather than by the author:
the inputAuthor toolbox stamps canonical defaults for a control whose name
the catalog knows, so a complete three-class binding is one call.
Kinds
kind | Shape per class | What it is |
|---|---|---|
button | an array of bindings — any one satisfies it | A press: jump, fire, interact |
axis1 | one binding | A scalar: throttle, zoom, lean |
axis2 | one binding | A vector: movement, look, aim |
A button takes an array because a control usually has more than one way to
be pressed on the same device (ShiftLeft and ShiftRight). An axis takes
one because an axis evaluates exactly one binding per class.
-- move.inputBinding/init.luau
return {
label = "Move",
kind = "axis2",
kbm = B.wasd(),
gamepad = B.padStick("left"),
touch = B.touchStick({ zone = "left" }),
}
Shaping an axis
An axis1 / axis2 control conditions its reading before a consumer sees
it. Four optional fields, applied every tick in this order:
| Field | What it does |
|---|---|
deadzone | A magnitude below it reads 0. On an axis1 that is the value; on an axis2 it is radial — the magnitude of the pair — so a diagonal held past the threshold keeps both components |
curve | "linear" (the default), "quadratic" (squares, keeping the sign), "cubic", or a function taking the reading and returning the shaped one |
invert | true negates the reading — both components, for an axis2 |
smoothing | The time constant, in seconds, the reading approaches its target over. 0 (the default) arrives at once |
-- look.inputBinding/init.luau
return {
label = "Look",
kind = "axis2",
deadzone = 0.12,
curve = "quadratic",
smoothing = 0.05,
kbm = B.mouseDelta(),
gamepad = B.padStick("right", { as = "delta", unitsPerSecond = 1200 }),
touch = B.touchDrag({ zone = "right" }),
}
The shaping lands on the reading the classes produced, after as /
unitsPerSecond / scale have brought each of them into the control's own
unit — so one deadzone is written once, in that unit, and covers the
stick, the mouse and the finger together.
smoothing carries the same value toward whatever the devices report next,
so a control whose context leaves, whose group stands down, or whose gate
closes drains to neutral rather than cutting to it, and reports its drop
frame when it arrives.
Shaping is refused on a button: it is held or it is not, so there is no
magnitude to cut, bend or approach. Say when a button may read at all with
gate.
Events
A binding exposes the events a controller subscribes to. Handlers take positional arguments, so a movement handler reads them directly rather than unpacking a table every frame.
| Event | Fires |
|---|---|
input | Every frame the binding is active, carrying (value, dt, active) — and once more on the frame it goes inactive, with active = false and the neutral value |
pressed | The press edge of a button |
released | The release edge of a button |
changed | When the value changes |
input firing on the drop frame is what lets one handler both start and
stop motion:
map.move:onInput(function(v, dt, active)
if not active then self.velocity = vec3.zero return end
self:translate(v * self.speed * dt)
end)
Ref methods
:record()— the validated binding record. Raises when a class is missing or the record is malformed, naming what is wrong.:problems()— every problem with the record, without raising. What an authoring tool reports so a whole map's faults surface at once.:inspect()—{ label, kind, classes }forasset.inspect..events— the subscribe-only view over the four events above.
Creating one
asset.create("inputBinding", name, opts) writes a record carrying every
device class, so a binding is complete the moment it exists:
asset.create("inputBinding", "boost", {
label = "Boost",
kind = "button",
kbm = '{ B.key("ShiftLeft") }',
gamepad = '{ B.padButton("left_shoulder") }',
touch = '{ B.touchButton({ zone = "right-lower" }) }',
})