Hands-on walkthrough: Tutorials — First input action.
Model
The engine separates device sampling from game actions.
- An
InputDrivergathers platform keyboard, mouse, and gamepad state. InputSystem.syncFramekeeps current and previous snapshots.ActionRegistryloads named bindings from JSON.ActionStateexposes resolved gameplay intent to the world/game.
The public entry point is src/hikari/src/input/input.zig. Frame data and key-trigger helpers are in input_frame.zig; action binding parsing and evaluation are in action_binding.zig, action_registry.zig, and input_actions_json.zig.
Frame semantics
At the start of a frame, syncFrame copies current device values to their previous slots and asks the driver for a new snapshot. Read helpers can therefore distinguish a held value from transitions without querying the operating system again mid-frame.
Input remains backend-neutral above the driver. Game and scene code read the normalized snapshot/action state; they do not call Cocoa, Win32, Metal, or D3D12 APIs.
Mouse wheel units
MouseFrame.wheel_y is a per-frame signed scroll sample. Its unit depends on MouseFrame.flags:
| Flag | Meaning | Typical source |
|---|---|---|
mouse_flag_wheel_precise | Layout points (trackpad stream) | macOS trackpad |
mouse_flag_wheel_momentum | OS inertia after fingers up (no live user sample this frame) | macOS momentumPhase |
| neither | Discrete line/notch units (±1 per click) | Windows WM_MOUSEWHEEL, macOS mouse wheel |
UI converts with ui.scroll_physics.wheelDeltaPoints / wheelDeltaPointsSens (precise path applies gain, not wheel_step). Momentum must not hold rubber-band open — only user samples do; outward momentum past an edge is ignored and damps residual velocity so spring-back can run. Non-UI consumers (fly camera) should use wheelLines.
Pointer mode (cursor capture)
PointerMode is free or captured. Captured hides the OS cursor and reports relative motion through the usual mouse position stream (mouseDelta / action delta_x/delta_y stay valid). Platform backends (macOS CoreGraphics association + Windows raw input) own hide/clip; focus loss always frees.
| Surface | Call |
|---|---|
| Game SDK | hi.world().setPointerMode(.captured) / .free |
| Host input | InputSystem.setPointerMode |
Editor play: Esc soft-unlocks the cursor (docks stay usable) without clearing the game’s requested mode; a left click on the scene viewport re-arms capture. Editor edit: temporary capture while fly-camera navigation is active. Standalone games typically capture when look is live and free when a settings/modal UI is open.
Input contexts
Code: src/hikari/src/input/input_context.zig.
A context is a named layer saying which actions are readable while it is active. Without one, every reader has to ask "…but is a menu open?" at each call site, and the day someone forgets, the player shoots through the pause screen.
Resolution walks layers highest priority first (equal priority → most recently pushed first):
- a layer that claims the action → readable;
- an exclusive layer that does not claim it → not readable, walk stops;
- falling off the bottom → readable.
So an empty stack allows everything and adopting contexts never changes an existing game's input. A non-exclusive layer only adds actions (build mode on top of normal control); an exclusive layer masks everything below (pause menu, dialogue, cutscene, text entry).
For each action read, the first matching decision wins:
Diagram source
flowchart TD
read["Read an action"] --> next{"Another context layer?"}
next -->|No| allow["Allow the read"]
next -->|Highest remaining priority| claim{"Layer claims this action?"}
claim -->|Yes| allow
claim -->|No| exclusive{"Layer is exclusive?"}
exclusive -->|Yes| block["Block the read"]
exclusive -->|No · continue downward| nextStorage is fixed and inline in World — 8 layers × 24 actions, no allocation, so a push cannot fail for memory reasons mid-frame. Over capacity returns StackFull / ContextTooLarge rather than truncating; both mean a missing pop or an oversized layer.
| Surface | Call |
|---|---|
| Game SDK | w.pushInputContext(.{ .name = "menu", .exclusive = true, .pointer = .free }) / popInputContext / hasInputContext / clearInputContexts / inputAllows |
| Kawa | Input.push_context({ name = "menu", exclusive = true, pointer = "free" }) / pop_context / has_context / clear_contexts / allows |
Both surfaces share one stack on World, so a layer pushed from either gates both. Every action read — pressed, held, value, and released — passes through it. Releases are gated deliberately: a button released while a menu is open belongs to the menu, and leaking it is how a weapon fires on the frame the pause screen closes.
pointer on a layer sets cursor mode while it is the topmost layer declaring one, which is usually the whole reason a menu layer exists. It replaces hand-written "am I in a menu" pointer syncing.
Raw input needs a binding-less action. Mouse delta and gamepad axes are not actions, so nothing gates them automatically. Declare a name like look in the gameplay layer with no binding and ask inputAllows("look") (Kawa: Input.allows("look")) before consuming raw look input.
The stack outlives the scene. Clear it on scene unload, or a reload stacks a second set of layers on the first.
Action maps
Authored actions always live at configs/input_actions.json (URI
config://input_actions.json, kind com.hikari.config.input_actions, schema
version 2). The authored/staged filesystem document wins over its AssetStore
blob. There is no path in startup policy.
The authored document is the action schema: it decides which action ids exist
and supplies every default binding. Runtime rebinding never replaces it. User
changes live separately under the product's writable root as sparse
configs/input_bindings.json overrides (kind
com.hikari.config.input_bindings, version 1). Only ids still present in the
authored registry are applied; removed ids are ignored and omitted from the
next save, while newly authored ids immediately receive their defaults.
An untouched session creates no user file. setActionBinding marks the live
registry dirty only when the value changes, and shutdown writes only bindings
that differ from authored defaults. Returning a binding to its authored value
therefore removes its override; when none remain, the user file is deleted.
Legacy per-user input_actions.json snapshots are deliberately ignored.
Authored format
Bindings name controls rather than exposing runtime bit masks. Fields are sparse: omit an unused device or a value that should keep its default.
{
"kind": "com.hikari.config.input_actions",
"version": 2,
"actions": [
{
"id": "move_forward",
"bindings": {
"keys": ["w", "up"],
"gamepad_buttons": ["dpad_up"]
}
},
{
"id": "move_x",
"bindings": {
"negative_keys": ["a", "left"],
"positive_keys": ["d", "right"],
"gamepad_axis": "left_x"
}
},
{
"id": "fire",
"bindings": {
"mouse_buttons": ["left"],
"gamepad_buttons": ["r1"]
}
}
]
}The accepted names are:
| Field | Values |
|---|---|
keys, negative_keys, positive_keys | w, a, s, d, q, e, r, f, x, c, v, p, y, z, f2–f6, tab, enter, space, escape, up, down, left, right, shift, primary_modifier, backspace, delete, home, end |
gamepad_buttons | a, b, x, y, l1, r1, l3, r3, menu, options, home, dpad_up, dpad_down, dpad_left, dpad_right |
mouse_buttons | left, right, middle |
gamepad_axis | left_x, left_y, right_x, right_y, left_trigger, right_trigger |
mouse_axis | delta_x, delta_y, wheel_y |
Each named array is OR-combined. negative_keys subtract one and
positive_keys add one, which makes a scalar action such as move_x directly
usable with actionValue. axis_scale defaults to 1, deadzone to 0.15,
and player to 0. A binding-less action is simply "bindings": {}.
Version 1 numeric-mask documents are intentionally rejected. Hikari is unreleased, so authored defaults should be converted to the named version 2 format instead of carrying a legacy reader.
Per-user override format
The engine writes this document; projects do not ship one:
{
"kind": "com.hikari.config.input_bindings",
"version": 1,
"bindings": [
{
"action": "move_forward",
"binding": { "keys": ["up"] }
}
]
}Editor input ownership
Editor retained UI and viewport navigation have separate ownership. Interactive editor panels consume their own input first; a hovered panel, including its wheel gesture, must not also navigate the viewport camera. EditorViewportController owns edit-camera movement through its narrow view-provider interface (scene_input_blocked + press-origin latches).
In Play, game UI and entity scripts receive scene-local dimensions and remapped input inside the viewport (World.gameInputFrame):
| Pointer mode | Sample outside scene rect |
|---|---|
| Free | Buttons, wheel, and inside cleared — dock/inspector clicks never reach mouse_left / look scripts |
| Captured | Motion and buttons kept (virtual aim point is unbounded; chrome is already inert) |
Do not re-implement "am I over chrome?" in game scripts — the remapped frame is the ownership answer.
Text entry
UI text fields read printable characters from KeyboardFrame.text each frame. Focus, caret, Backspace/Delete, and Left/Right live in the UI layer; the platform only fills the text buffer and key bits.
Desktop platforms stream physical keyboard characters into that buffer. Consoles that cannot type directly open a native virtual keyboard through a short text-input session on InputDriver:
beginTextInput/endTextInput— edge-triggered when any focused editable field wants text- Session hosts call
SessionCore.syncTextInputonce per frame after all UI (game + editor) has run - Virtual-keyboard results still land in
KeyboardFrame.text(streaming or bulk inject on dismiss)
Do not add a second text path in UI code. Platform SDKs own soft keyboards; UI owns buffers and caret.
Extending input
Add a platform device implementation behind InputDriver, normalize its state into InputState, and expose bindings/actions through the shared action layer. Do not make scene code platform-aware or build a second action-resolution path in a driver.