---
title: "Input"
description: "A player might be at a keyboard, holding a controller, or tapping a phone. Input in Zero is built so one piece of code answers to all three, and so a world cannot quietly end up working on only one…"
section: "Topics"
slug: "topics-input"
canonical: "https://origozero.ai/docs/topics-input"
updated: "2026-09-06T20:54:35.082874692+00:00"
tags: ["documentation", "guide"]
---

# 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.

```lua
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
```

```lua
-- 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:

```lua
-- 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:

```lua
-- 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:

```lua
-- driving.inputMap/init.luau
return {
    description = "Steering, throttle, brake.",
    group = "vehicle",
    suppresses = { "player" },
}
```

```lua
-- 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:

```lua
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.

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.

```lua
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:

```lua
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:

```lua
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.
