In this guide
Action maps, bindings, contexts, gamepads, touch, and deterministic replay.
Input
Input is action-based and contextual. Keyboard, mouse, gamepads, touch, gestures, and HUD virtual controls all feed the same action queries, so gameplay code never cares which device produced the signal.
Declare actions in the project
"input": {
"presets": ["wasd_arrows", "confirm", "gamepad_confirm", "move_3d"],
"actions": {
"move": {
"type": "vector2",
"context": "gameplay",
"bindings": [
{ "input": "key:a", "vector": [-1, 0] },
{ "input": "key:d", "vector": [1, 0] },
{ "input": "gamepad:stick:left", "deadzone": 0.18 },
{ "input": "virtual:move" }
]
},
"jump": {
"type": "button",
"context": "gameplay",
"bindings": [
{ "input": "key:space" },
{ "input": "gamepad:south" }
]
}
}
}
Each action is a named logical control. Bindings list the physical inputs that feed it:
typeisbutton(pressed or not),axis1d(one scalar −1…1), orvector2(two scalars).contextgroups actions so menus can disable gameplay without touching scripts. Defaults togameplay.vectoron a key binding maps a key to a direction (used for WASD).deadzone(0…0.99) ignores small stick deflection.scale(−1…1) andinvertflip or shrink an axis.normalizenormalizes that binding’s own contribution to unit length.playerroutes the binding to a local player slot.
Prefer the input presets — wasd_arrows, confirm, gamepad_confirm, move_3d — and override only what you need.
Device names
| Pattern | Examples |
|---|---|
key:<key> | key:w, key:space, key:left_shift |
mouse:<button> | mouse:left, mouse:right, mouse:middle |
gamepad:<button> | gamepad:south, gamepad:start, gamepad:dpad_up |
gamepad:axis:<axis> | gamepad:axis:left_trigger, gamepad:axis:right_x |
gamepad:stick:left|right | 2D stick, respects deadzone |
virtual:<control> | virtual:move, virtual:move:x, virtual:fire |
Names are case-insensitive. HUD virtual_button and virtual_stick nodes publish virtual:<control> bindings.
Action queries
local Input = require("demi.input")
if Input.pressed("jump") then
-- true only on the frame the action went down
end
if Input.down("fire") then
-- true every frame while held
end
if Input.released("fire") then
-- true only on the frame it came up
end
All three return a plain boolean. Call them once per frame and store the result if you need it in several places.
local strength = Input.value("accelerate") -- −1 … 1 (axis1d)
local x, y = Input.vector("move") -- two scalars, clamped to a unit circle
local rx, ry = Input.raw_vector("move") -- two scalars, unclamped
local source = Input.source("jump") -- "key:space", "gamepad:south", …
valueis how hard an analog control is pressed; buttons report 0 or 1.vectoris safe to feed straight into movement (already normalized).raw_vectorkeeps the unnormalized values when you want speed to scale with stick deflection.sourcenames the binding that produced the current value — useful for “press [key]” prompts.
Raw key helpers bypass actions and read hardware directly. Use them for debug tools and editors:
if Input.key_down("left_shift") then end
if Input.key_pressed("f1") then end
if Input.key_released("f1") then end
Need a one-dimensional value from two buttons? There is no Input.axis helper. Declare two axis1d actions, or check the buttons in Lua and combine them yourself:
"input": {
"actions": {
"turn_left": { "type": "axis1d", "bindings": [ { "input": "key:a", "scale": 1 } ] },
"turn_right": { "type": "axis1d", "bindings": [ { "input": "key:d", "scale": 1 } ] }
}
}
local turn = Input.value("turn_right") - Input.value("turn_left")
Contexts
Input.enable_context("menu") -- menu actions become live
Input.disable_context("gameplay") -- gameplay actions go silent
if Input.context_enabled("gameplay") then end
Only actions whose context is currently enabled respond. An empty enabled set means every action reads as released. This is how a modal menu takes over without editing gameplay scripts.
Gamepads, rebinding, and persistence
Input.assign_gamepad(1, 0) -- device index 1 → player 0; true when applied
local count = Input.gamepad_count() -- how many are connected
Input.rebind("jump", 1, "key:e", 0) -- action, binding index (one-based), new control, player
local ok = Input.save_bindings("input/bindings.json") -- true on success, plus an error string
local ok = Input.load_bindings("input/bindings.json") -- true when loaded
save_bindings and load_bindings both return a success boolean and an error string. Relative paths resolve below Application.user_data_path(), so player remaps survive updates and never write into the game install; absolute paths are used as supplied.
Touch and gestures
for _, t in ipairs(Input.touches()) do
-- t.id, t.phase ("began"|"moved"|"stationary"|"ended"|"cancelled"),
-- t.x, t.y, t.dx, t.dy, t.pressure
end
for _, g in ipairs(Input.gestures()) do
-- g.type ("tap"|"double_tap"|"long_press"|"drag"|"pinch"|"rotate"),
-- g.pointer_id, g.x, g.y, g.dx, g.dy, g.value
end
print(Input.touch_count())
Both return arrays (empty when nothing happened this frame). Related helpers: Input.mouse_down, mouse_position, mouse_delta, mouse_world_position, viewport_size, ui_pointer_captured (true when a HUD control owns the pointer), text_entered, set_text_input_active.
Deterministic replay
Replay fixtures record action frames and re-apply them through the authored context filter. Enabled recorded actions take precedence over physical bindings. The project fixed timestep must match the fixture.
demi run \
--input-replay fixtures/smoke.replay.json
See Testing & debugging for E2E-driven input and replay workflows.