Log inGet started

Input

Updated October 12, 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.

public = {
    inputMap = Field.assetRef("inputMap", "@builtin::inputMaps.default", NoSync, "The controls this component reads."),
    speed = Field.number(5, NoSync, "World units per second at full deflection."),
    jumps = Field.number(0, NoSync, "How many times the jump control was pressed."),
}

local controls

function awake()
    controls = public.inputMap:activate()

    controls.move:onInput(function(v, dt, active)
        if not active then return end
        local p = public.entity.position
        local step = public.speed * dt
        public.entity.position = { p.x + v.x * step, p.y, p.z - v.y * step }
    end)

    controls.jump:onPressed(function() public.jumps += 1 end)
end

function onDestroy()
    public.inputMap:deactivate(controls)
end

Keep what activate returned and hand it back to deactivate: those handles are this holder's claim on the map, and releasing the claim disconnects the subscriptions made through it. See Holders below.

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 button already held when its map comes up (a sprint key held through the flip to play, or through a respawn) reads held from the first frame, so onInput reports it and the sprint carries on. Its press happened before the control existed, so onPressed and onReleased stay quiet for that hold and answer the next press: a map activated from inside another map's press handler does not read that same press as a press of its own.

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
local controls

function onEquip()
    controls = self.weaponMap:activate()
    controls.fire:onPressed(function() self:shoot() end)
end

function onDrop()
    self.weaponMap:deactivate(controls)
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.

Holders

Several things can hold the same map at once — two guns of the same kind, a camera rig and the character it follows. Each activate opens its own claim and hands back its own handles, and every subscription made through those handles belongs to that claim.

deactivate(claim) releases one claim: the map leaves the live set only when the last claim goes, and the subscriptions that claim made are disconnected either way. So a rig releasing while the character keeps playing takes the rig's handlers down and leaves the character's running, and Zin.scheme.subscriberCount("jump") counts what is still listening rather than everything that ever listened.

deactivate() naming no claim releases the caller's own most recent claim, so a component that activated in awake gives up what it took even while another holder stands on the same map; where the caller holds none of them, the map's most recent claim goes. An asset ref is shared by every holder of the same map, so the handles are what say which holder is releasing — name the claim from a module or a helper, whose activate and release can run under different callers. Subscribing through handles whose claim was already released is an error: activate again, and subscribe through what that call returns.

Zin.scheme.live() reports each live map's holders — how many claims are still standing on it — and heldBy, one record per claim saying who opened it: kind is "component" for an activation a component script made and "execute" for one made from a bare call, entity is the entity a component's activation sits on, source is the chunk:line it was opened at, label is what the caller called it, and holder says the same thing in a sentence. Pass that label as activate("weapon-swap").

That is what a control going silent under a suppression is asking about. Zin.observe.whySilent(name) names the map standing the control down in suppressedBy and that map's holders in suppressedByHolders, because the map is not the thing to go and change — one of its activations is, and a probe run once from an execute reads there beside the components.

Every activate reads the map's <name>.inputBinding/ children again, so a control added, edited or removed since the map came up reaches the running set — and reaches the holders already standing on it, whose handles and subscriptions carry on across the edit rather than being replaced.

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
local controls

function onDriverEntered()
    controls = self.drivingMap:activate()
    controls.throttle:onInput(function(v, dt) self:accelerate(v, dt) end)
end

function onDriverExited()
    self.drivingMap:deactivate(controls)
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. A move axis 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.

A name reaches a button through the same resolution everywhere: case and word separators fold away, so "South" and "LeftShoulder" reach the buttons the canonical spellings name, and a name outside the list is refused with the list in the message. Key names resolve the same way against the KeyboardEvent.code vocabulary ("KeyW", "Space", "Enter", "ShiftLeft"), with a single letter or digit accepted as shorthand for its Key<L> / Digit<N> code, so a simulated press lands on the code a keyboard reports rather than under a name only the simulator can press.

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
iconnoneA glyph drawn on the circle in place of the label

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.

Styling the overlay

Zin.touchControls.setAppearance(opts) states how the overlay is drawn, so a game that typesets its own interface puts the controls the engine draws in the same faces, colours and margins:

Zin.touchControls.setAppearance({
    inset = 38,               -- the margin the column keeps from the edges
    gap = 20,
    fill = "#10243aa0",
    stroke = "#c9a227",
    strokeWidth = 2,
    textColor = "#f2e6c8",
    fontFamily = "Inter",     -- any family `ui.listFonts()` names
    fontScale = 0.36,
})

inset and gap decide where the buttons sit. The column keeps inset from the edges of the safe area, the part of the screen no status bar, navigation bar, camera cutout or on-screen keyboard covers (ui.safeArea()), so on a phone the controls stay clear of all of those on their own; a game keeping a margin of its own states it here and the touch controls keep it too. fill / stroke / strokeWidth draw each circle, textColor / fontFamily / fontScale its label, iconFont / iconScale an icon glyph, sheetFill / sheetRounding the overflow sheet, fanFill / fanStroke the fan, and stickStroke / stickFill / stickWidth the movement stick. A call states only the keys it carries; calling it with no argument restores the engine's own.

A single button states any of the button keys for itself, over whatever the overlay is set to:

local Icon = require("@builtin::modules.icons")

B.touchButton({
    zone = "right-lower", label = "Fire", icon = Icon.Bomb,
    fill = "#7d1d1d", stroke = "#e8b33a", size = "large",
})

icon is a glyph drawn in the label's place, set in iconFont - which defaults to phosphor, the family modules.icons names. appearance() reports what is in force, and each entry of Zin.touchControls.layout() carries the style its own button resolved to.

Back

Back is the platform's own: the Back button and back gesture of an Android phone, and a keyboard's Back key (BrowserBack, the name a browser gives it too). The engine routes every press to the first of these that stands:

  1. the topmost overlay on screen that states how it is dismissed, an open popup carrying onDismiss or an open, dismissible modal carrying onClose. Its dismissal runs, exactly as Escape runs it, so a menu or dialog built from those closes on Back with no code.
  2. a claim on Back, Zin.back.claim(handler): the highest priority, and the most recent among equals, runs its handler.
  3. nothing: the platform's default answers. On Android the app moves to the background; a desktop window and a browser page stay where they are.

A claim states ahead of the press that the world takes Back, which is what Android asks before a back gesture begins. Hold one while Back means something (a pause screen to bring up, a sub-menu to step out of) and release it when Back should leave:

local Zin = require("@builtin::modules.zinput")

local claim
local function resume()
    pauseScreen.hide()
    if claim then claim:release() claim = nil end
end
local function pause()
    pauseScreen.show()
    claim = Zin.back.claim(resume)
end

A claim a component makes is released when its entity is destroyed. The editor claims Back at priority -1 while a scene is played, so Back stops play once nothing in the world takes it. To make Escape or a pad's button Back as well, bind the world's own control to Zin.back.press, which routes a press the same way. Each press is a back event in input.events(), { kind = "back", taker = "overlay" | "claim" | "none" }.

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.

action drives a control by name — move, jump, whatever bindings lists — with the value the game should read, which is how to play a game from a script: the same call works whatever device the player would hold. The value joins the control like another device (Zin.scheme.drive is the layer underneath; Zin.test.action the test helper), so shaping, edges, contexts and suppression all apply, and fired reports it under the class driven.

What inputSim simulates is the game's: it reaches the scene whatever a person at the window has focused, and no UI widget receives it, so a run testing the game and a person typing into an editor panel never reach into each other. Pass "ui" as a verb's trailing target to reach the UI the way a person's device does.

The point click and mouseMove take is in physical pixels, in the space their receiver reads positions in. For the game (the default) that is the space capture and worldToScreen report, the scene's own pixels. For the UI ("ui") it is the window, where every widget is recorded: a gui.bounds rect times ui.pixelRatio(), wherever the widget's screen draws.

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.

Each row belongs to one live map, so a control name two maps both declare reports once per map under the map's own name and count. The row also carries subscribers and delivered, which is how a press that fired and reached nobody reads apart from one that reached the game.

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. A key pressed after a click on an editor panel is the editor's (see "Whose keys the scene holds" below) and carries consumed_by_ui too. 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.

Whose keys the scene holds

In the editor the scene shares the window with panels, and a key belongs to the surface it was pressed on — the way a pointer gesture belongs to where it began. The scene holds the keys pressed while it was the active input context: the last click landed on the viewport, the pointer is locked to it, or play just started. A key pressed after a click on a panel is the editor's for as long as it is held: no scene-surface map, Zin.state.keyDown poll or event handler sees it, even once the context is back on the scene. A game running with no editor around it is the whole window, and every key is its.

The editor's own maps declare surface = "editor" (see types/inputMap), and editor code reads the same view with Zin.state.keyDown(code, "editor"). When the window loses focus, every key and button its keyboard and mouse held is released, so nothing stays held while the author is in another app.

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