Input
That shape comes from two rules.
A control is an asset, and it carries every device. Not a key your code checks for — a thing with a name, a label a player reads, and a binding for keyboard, gamepad and touch. All three are required; a control missing one is refused when its map activates, naming what it lacks. A control that works on a desktop and not on a phone is the failure this prevents, so it is made unrepresentable rather than found later by someone holding a phone.
Nothing is live until something asks for it. No scheme is active by default. A controller activates the map it needs, and the live controls are exactly what awake components asked for. An empty world has an empty input surface and an empty screen — no phantom Jump button on a game with no jump.
Reading input
Subscribe. There is no per-frame polling API for gameplay.
function awake()
local map = self.inputMap:activate()
map.move:onInput(function(v, dt, active)
if not active then self.velocity = vec3.zero return end
self:translate(v * self.speed * dt)
end)
map.jump:onPressed(function() self:jump() end)
end
function onDestroy()
self.inputMap:deactivate()
end
onInput fires every frame the control is active, and once more on the
frame it goes inactive, carrying the neutral value and active = false.
That last call is what lets one handler both start and stop the motion it
drives — no second subscription to notice the release, and no state left
running because nothing told it to stop.
A control that is entirely input-driven needs no update(dt) at all.
| Handler | Fires | On |
|---|---|---|
onInput(value, dt, active) | Every frame active, plus the drop frame | Any control |
onPressed() / onReleased() | The press and release edges | A button |
onChanged(value) | When the value changes | Any control |
Every handler is on every control. A button answers onInput too — with
value as true while it is held — which is what you want for something
held rather than tapped: a sprint that lasts as long as the button does
reads the same way a stick does, and gets the drop frame when it ends.
onPressed is for the moment, onInput for the duration.
A control the map does not declare reads as nil, so a typo raises at the
subscribe call rather than becoming a control that never fires. A
controller that works with more than one map tests for one first —
if map.crouch then map.crouch:onInput(...) end — and loses only the
capability that control carries.
Authoring a scheme
An .inputMap is a folder, and its controls are the
<name>.inputBinding/ folders inside it — the same containment a
.toolbox has with its .tool children. Containment is what records the
reference, so a scene carrying its map carries every control it declares. A
scene pulled into another world cannot arrive somewhere boost binds to
nothing.
racer.inputMap/
init.luau -- { name = "racer" }
boost.inputBinding/
init.luau
steer.inputBinding/
init.luau
-- boost.inputBinding/init.luau
local B = require("@builtin::modules.zinput.bindings")
return {
label = "Boost", -- what the touch button shows, and what a
kind = "button", -- rebind screen calls this control
kbm = { B.key("ShiftLeft") },
gamepad = { B.padButton("left_shoulder") },
touch = { B.touchButton({ zone = "right-lower" }) },
}
kind | Shape per class | What it is |
|---|---|---|
button | an array — 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 one device (ShiftLeft and ShiftRight). An axis takes one
because an axis evaluates one binding per class.
Set a class to false to suppress it deliberately — a text-entry control
with no touch counterpart says so, in the record where a reader sees it.
The inputAuthor toolbox writes these, filling in canonical defaults for a
control whose name it recognises, so a complete three-class binding is one
call rather than thirty lines.
Shaping an axis
An axis1 / axis2 control conditions its reading before a consumer sees
it. Four optional fields, applied in this order every tick:
-- look.inputBinding/init.luau
return {
label = "Look",
kind = "axis2",
deadzone = 0.12, -- magnitude under this reads nothing
curve = "quadratic", -- "linear" | "quadratic" | "cubic" | a function
invert = false, -- negate it
smoothing = 0.05, -- seconds it takes to approach a new reading
kbm = B.mouseDelta(),
gamepad = B.padStick("right", { as = "delta", unitsPerSecond = 1200 }),
touch = B.touchDrag({ zone = "right" }),
}
On an axis2 the deadzone is radial — it is measured on the magnitude
of the pair, so a diagonal held past it keeps both components instead of
being clipped into a cross. "quadratic" squares while keeping the sign,
so a stick pushed halfway reads a quarter in the direction it was pushed.
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, written once in that unit, covers the stick, the
mouse and the finger together.
smoothing is the one that carries state: the value approaches its target
over that many seconds rather than stepping onto it, and a control whose
context leaves or whose group stands down drains to neutral the same way
instead of cutting.
A button refuses all four — it is held or it is not, so there is no
magnitude to cut, bend or approach.
More than one map
Maps compose. Several can be live at once and the player's surface is the union of them, so a capability brings its own controls with it:
-- Gun.component — a weapon adds Fire, and takes only Fire away
function onEquip()
local map = self.weaponMap:activate()
map.fire:onPressed(function() self:shoot() end)
end
function onDrop()
self.weaponMap:deactivate()
end
Fire appears beside the movement stick. Dropping the gun removes that one
button and nothing else. This is the common case, and it needs nothing but
activate / deactivate on the thing that has the capability.
The other case is one thing taking over: in a car you must not still be able to jump, and with a menu open nothing in the game may answer at all. A map names the group its controls belong to, and the groups it stands down while it is live:
-- driving.inputMap/init.luau
return {
description = "Steering, throttle, brake.",
group = "vehicle",
suppresses = { "player" },
}
-- Car.component
function onDriverEntered()
local map = self.drivingMap:activate()
map.throttle:onInput(function(v, dt) self:accelerate(v, dt) end)
end
function onDriverExited()
self.drivingMap:deactivate()
end
The car never touches the walk map. It stands down the whole player
group by naming it, and walking resumes the moment the car releases —
which is what has to happen, because the car still exists after the
player gets out. Liveness follows who has control, not what exists:
awake / onDestroy are the wrong hooks for anything a player enters and
leaves.
A suppressed map keeps its holders, its handles and every subscription made through it. It stops reading devices and its buttons leave the screen; a stick held at the moment it stands down reports its neutral value once, so whatever it drove stops rather than sticking.
Declaring neither field composes with everything — right for a map that is
the only thing a world runs. A map that declares no group belongs to one
named after itself, so it can still be named by something that wants to
stand it down.
A menu is the same mechanism pointed at everything:
return { group = "ui", suppresses = { "player", "vehicle", "weapon" } }
Zin.scheme.live() reports each map's group, what it suppresses, and
its suppressedBy — the answer to "this control is live and nothing
happens".
Which way is forward
Two frames, and which binding speaks which:
- Stick bindings —
B.wasd(),B.padStick(),B.touchStick()— report the direction the player means. Forward is+y. Amoveaxis carrying a keyboard class and a stick class drives both devices the same way. - Delta bindings —
B.mouseDelta(),B.touchDrag()— report screen-space movement. Down is+y, because that is what the pointer did.
The platforms underneath keep their own signs; the conversion happens at the binding, which is the layer where a keyboard class and a stick class meet on one axis.
Gamepad
Pad buttons are named by position, never by the letter printed on them:
south, east, west, north, left_shoulder, right_shoulder,
left_trigger, right_trigger, left_stick, right_stick, select,
start, guide, and the four dpad_*. south is the lower face button
on every pad, so one binding evaluates against any of them.
Which glyph a prompt draws is a display concern the binding layer never
sees. Zin.gamepad.label("south") resolves it from the connected pad —
A on an Xbox pad, Cross on a PlayStation one, B on a Nintendo one.
ui.text("Press " .. Zin.gamepad.label("south") .. " to jump")
Zin.gamepad also answers how many pads are connected and which family
each belongs to — what a local-multiplayer lobby needs.
Naming a slot on a binding picks one pad; omitting it means any connected
pad, which is what a single-player scheme wants: whichever controller the
person picked up drives the control, with no pairing step.
Touch
On a touch surface the on-screen controls appear by themselves, built from
the live bindings. A binding with a touchStick gets a stick; one with a
touchButton gets a button labelled from the binding's label. Nothing
appears that no live binding declares.
A world that activates no map of its own still gets controls. The engine
arms the builtin default map as a keyboard floor the moment an action or
axis name is read with nothing behind it, and the overlay draws that
floor's own touch class — a movement stick, a look zone, and Jump, Sprint
and Crouch — so a world written against raw keys is playable on a phone
with no setup at all. The floor yields to the world: the moment a live
scheme declares a touch control of its own, the overlay draws that scheme
alone, so a game with its own controls never shows the engine's beside
them. A button the game attached with Zin.touchControls.button is its
own and draws either way. A map that stands the player group down —
suppresses = { "player" }, which is what the builtin controls belong to
— takes the floor off the screen outright, whether or not the game
declares touch controls itself.
B.touchButton(opts) shapes the layout:
| Opt | Default | Effect |
|---|---|---|
priority | 50 | Lower renders first / more prominently |
size | "medium" | This button's circle radius: 40 / 56 / 72px |
group | none | Buttons sharing a group render adjacently |
size is per button: a scheme mixing a large action button with a
small pause pip draws them at 72 and 40 whatever else is on screen, and
Zin.touchControls.layout() reports each button's own radius. The
overlay budgets buttons into a two-column band on the right edge, packing
the column on the largest live button's pitch — so the set decides where a
button sits, and the button's own size decides how big it is. A scheme
with more than four walks every button one step down its own size, which
layout() reports as appliedSize beside the declared size; past the
two-column budget the overflow collapses behind a fan that opens on tap.
Set priority on the controls a player reaches for without looking, and
group on clusters that read as one gesture.
Testing it on every device
The inputSim toolbox drives all three classes and reads back what
actually reached the game. A world checked only from a keyboard is a world
whose keyboard experience is the only one anybody verified.
- device — present as a phone, a desktop, or a pad.
- bindings — which controls are live.
- layout — what is on screen.
- tapButton — tap a named control where a player would.
- fired — what reached the game.
- held — what is still down.
Read bindings first. Nothing is live until a component activates a map,
so an empty list means no awake component asked for input — a different
problem from a control that fires and does nothing.
fired is what turns a simulated input into an assertion: it reports which
controls fired since you last asked and from which device class, and clears
as it reads. A capture can look identical whether or not the input landed.
layout is how a control nothing consumes gets caught — a label on screen
with no matching entry in bindings.
held names what the session still has down — keys, mouse buttons, contacts,
the on-screen stick, connected pads — so a control that keeps firing between
takes reads as the key that is still pressed. release lets go of that whole
set in one call, which is how a take starts from rest.
For the gamepad, pad takes the device name, which decides the legends a
prompt draws:
pad takes that device name, padPress a face or shoulder button by
compass name (south, east, ...), and padStick a stick, its x and y, and
how long to hold it.
Why a control did not fire
A control can be live, its device can be producing input this frame, and the
game can still receive nothing. Zin.observe answers why in one call:
local Zin = require("@builtin::modules.zinput")
Zin.observe.whySilent("look")
--> { reason = "gateRefused", layer = "scheme", map = "default",
-- means = "the control's own gate answered no. A look control gates on
-- the player steering, so a cursor crossing the window reads
-- nothing.", kind = "axis2", subscribers = 1, ... }
The answer is one name from a closed set, and Zin.observe.reasons() lists
the set with a sentence each:
noMapActive · unknownControl · groupSuppressed · contextInactive ·
heldOnArrival · gateErrored · gateRefused · noBindingForDevice ·
belowDeadzone · noSubscriber · atRest · delivering
atRest and unknownControl are the pair no value reader can separate: a
misspelled name and a control nobody is touching both read 0.
Zin.observe.control(name) puts everything about one control in a single
call — which maps contribute it, its bindings per device class, its
subscriber count, what it reported on the last tick, and why. Zin.observe .frame() is the whole tick: every live map, every control with what it did
and why, and what the tick cost. Both report one tick — the most recent
one — and reading takes nothing away from the next reader, so two observers
in the same frame both get the truth.
The binding layer's own per-frame cost lands in
profiler.stats("*zin.scheme.advance*") as script.zin.scheme.advance.
Reading the input layer without running a script
/runtime/input serves the same answers as a file, beside the engine's own
account of the frame's device events:
vfs.read("/zero/runtime/input") -- the whole document
vfs.read("/zero/runtime/input/events") -- events, each with consumed_by_ui
vfs.read("/zero/runtime/input/device") -- pointer lock, UI focus, devices
vfs.read("/zero/runtime/input/mapping") -- the mapping layer's tick
vfs.read("/zero/runtime/input/controls/look") -- one control's entry
input.observe() returns the same document to a script. Every device event
carries consumed_by_ui — whether the UI layer took THAT event, judged
against the focus the UI holds over the surface it arrived on, so a key
typed while the cursor merely hovers a panel is told from a key typed into a
text field. It is the same value a Zin.input.on* or Zin.events.on
handler receives as its second gpe argument.
The mapping half of the document costs a frame of work to build, so it is
built only while something is reading. A read arms it for a window of
frames; the document's mappingStatus says whether what it carries is
current, stale, arming (this read armed it — read again), or
unarmed. A read taken from a script during frame N answers for frame N-1,
since the engine publishes at the end of a frame.
Raw state
Zin.state.keyDown and friends read the per-frame snapshot beneath the
binding layer — the layer rebind capture and editor tooling operate on.
Polling it from gameplay makes that code keyboard-only, and draws the
engine-raw-key-poll diagnostic at author time.
Text entry and pointer routing sit outside the control model on purpose: typing into a field and egui's pointer focus are not controls, and binding them would be wrong.