Log inGet started

Input

Updated 6 September 2026

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.

HandlerFiresOn
onInput(value, dt, active)Every frame active, plus the drop frameAny control
onPressed() / onReleased()The press and release edgesA button
onChanged(value)When the value changesAny 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" }) },
}
kindShape per classWhat it is
buttonan array — any one satisfies itA press: jump, fire, interact
axis1one bindingA scalar: throttle, zoom, lean
axis2one bindingA 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 bindingsB.wasd(), B.padStick(), B.touchStick() — report the direction the player means. Forward is +y. A move axis carrying a keyboard class and a stick class drives both devices the same way.
  • Delta bindingsB.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:

OptDefaultEffect
priority50Lower renders first / more prominently
size"medium"This button's circle radius: 40 / 56 / 72px
groupnoneButtons 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.

  • documentation
  • guide