Input
Input in Zero is built around named actions, not raw key polling. Rather than scattering is the W key down? checks through your code, you bind physical inputs (keys, mouse, gamepad, gestures) to…
The input library
local Zin = require("@builtin::modules.zinput")
zinput is a full input system: action bindings, analog axes, gamepad and pointer support, chords, gestures, contexts, and rebinding. Because the surface is broad, discover it rather than guessing — asset.inspect("@builtin::modules.zinput") and its submodules (@builtin::modules.zinput.actions, .axes, .map, .touch, .gestures, .surface, .bindings, .pointer, …) show what each piece does, and lsp resolves their exports. Read the library; it's the source of truth for the call shapes.
Reading input
local move = Zin.axes.value("move") -- {x, y} movement vector
local look = Zin.axes.value("look") -- {x, y} look delta, gated
if Zin.actions.pressed("jump") then jump() end
if Zin.actions.held("sprint") then speed = runSpeed end
No setup is required: the first action or axis read activates the default control scheme and starts the input tick. Code written against actions and axes plays identically on keyboard, touch, and gamepad — on touch surfaces the virtual movement stick, action buttons, and drag-look zone appear on screen automatically, with no opt-in from the world.
Per-frame input handling belongs in a long-lived loop or a component (the scripting-and-tasks and components guides), where you read the current action/axis state each frame.
Default control scheme
The default scheme's action and axis names are canonical — every builtin controller and demo reads these. vehicle_* entries are active only while the "vehicle" input context is pushed; everything else is active by default.
| Name | Kind | Default kbm binding | Touch behavior |
|---|---|---|---|
move_forward | action | W | none — move axis carries movement on touch |
move_back | action | S | none |
move_left | action | A | none |
move_right | action | D | none |
move_up | action | Space, E | none |
move_down | action | Ctrl, Q | none |
sprint | action | Shift | button, lower-right |
crouch | action | Ctrl | button, lower-right |
jump | action | Space | button, lower-right |
pointer_lock_toggle | action | Escape | none |
look_drag | action | right mouse button | none |
fly_up | action | E, Space | none |
fly_down | action | Q, Shift | none |
vehicle_throttle | action | W, ↑ | none |
vehicle_reverse | action | S, ↓ | none |
vehicle_brake | action | Space | button, lower-right |
move | axis (vector) | WASD | left virtual stick |
move_vertical | axis (scalar) | Space / Ctrl | none |
look | axis (vector) | mouse delta, gated on pointer-lock or held right mouse button | right drag zone, gated on drag activity |
look_x | axis (scalar) | mouse delta x, same gate as look | none |
look_y | axis (scalar) | mouse delta y, same gate as look | none |
scroll_y | axis (scalar) | mouse wheel | two-finger pinch |
vehicle_steer | axis (scalar) | D / A | left virtual stick |
Custom schemes
A custom scheme is authored as an .inputMap asset: explicit per-device-class bindings (kbm, touch, gamepad) for each action and axis, optionally extending another map. Zin.map.bake(name) writes the current live scheme out as a new .inputMap instance — a convenient starting point to hand-edit rather than writing bindings from scratch. Activate a scheme with Zin.map.activate(record), or Zin.map.ensureActive() to fall back to the default when nothing else is active.
Touch-button layout opts
B.touchButton(opts) takes three options beyond zone/label/icon that shape how the touch overlay lays a button out:
| Opt | Type | Default | Effect |
|---|---|---|---|
priority | number | 50 | Lower renders first / more prominently. Zin.map's synthesis (a boolean kbm action with no explicit touch class) assigns 100, so an authored button always outranks a synthesized one at the same context. |
size | "small" | "medium" | "large" | "medium" | Circle radius: 40 / 56 / 72px. |
group | string? | none | Buttons sharing a group render adjacently — the whole group is positioned by its lowest-priority member. |
Design your touch layout
The touch overlay budgets buttons into a two-column band along the right edge; a scheme with more than four touch buttons drops every button one size step and, past the two-column budget, collapses the overflow behind a fan button that opens a grid sheet on tap. A scheme with a handful of actions never notices this — it's built for schemes with a dozen-plus touch buttons (fighting games, crafting menus, vehicle HUDs) that would otherwise overflow a phone screen as one tall unusable column. Set priority on the actions that matter most in the moment (the ones a player reaches for without looking), and group on button clusters that read as one gesture (attack + block, a radial weapon-select) so budgeting never splits them apart.
Raw state is for tooling, not gameplay
Zin.state.keyDown and friends read the raw per-frame snapshot underneath the binding layer — the layer that rebind capture, the touch-controls key-emulation floor, and dev tooling operate on directly. Polling it from gameplay code makes that code keyboard-only (it never sees touch or gamepad input) and draws the engine-raw-key-poll diagnostic at author time. Bind an action or axis instead.
Simulating input
The sim toolbox drives synthetic input — for testing a control scheme, scripting a demo, or an agent driving the world from execute(). Run its tools with tools.use("sim", "<name>", ...); key arguments are browser KeyboardEvent.code strings ("KeyW", "Space", "ShiftLeft", "Enter", "Escape", "ArrowUp"), not bare letters. They inject the same events real devices would, so anything zinput reads, sim can produce:
tools.use("sim", "key", "KeyW") -- tap a key (optional trailing arg = hold seconds)
tools.use("sim", "key", "KeyW", 2) -- hold W for 2 seconds
tools.use("sim", "keyDown", "ShiftLeft"); tools.use("sim", "keyUp", "ShiftLeft")
tools.use("sim", "mouseMove", x, y); tools.use("sim", "click")
tools.use("sim", "scroll", dy)
tools.use("sim", "lockPointer", true)
tools.use("sim", "recordMacro"); tools.use("sim", "saveMacro", name); tools.use("sim", "macro", name) -- record + replay sequences
The model
Bind physical inputs to named actions with zinput, read those actions each frame, and use sim when you need to produce input rather than receive it. Reach for asset.inspect on the zinput modules for the exact API — the named-action approach is the part worth internalising.