Enabling action groups, remapping, and persistence.
Contexts & rebinding
A context is a named group of actions that can be switched on and off together. It exists so a pause menu can silence gameplay without editing gameplay scripts. Rebinding is how a player changes which physical input feeds an action, and persistence saves those choices between runs. This page builds a pause menu, assigns gamepads, and stores a remap.
What a context is
Every action already declares a context (default gameplay). Only actions whose context is currently enabled respond to queries; the rest read as released. Two contexts cover most games:
"input": {
"actions": {
"move": { "type": "vector2", "context": "gameplay", "bindings": [] },
"jump": { "type": "button", "context": "gameplay", "bindings": [] },
"ui_ok": { "type": "button", "context": "menu", "bindings": [] }
}
}
gameplay— movement, combat, everything in the world.menu— navigation and confirm/cancel while a menu owns the screen.- You can name more contexts (
driving,typing) and enable several at once.
Enabling and disabling
local Input = require("demi.input")
Input.enable_context("menu")
Input.disable_context("gameplay")
local live = Input.context_enabled("gameplay") -- boolean
Input.enable_context(name)turns a group on. No return value.Input.disable_context(name)turns a group off. No return value.Input.context_enabled(name)returnstruewhen the group is on, elsefalse.
One rule matters most: if the enabled set is empty, every action reads as released. That is the safe default when a cutscene or loading screen owns input.
Worked example: a pause menu
Skyforge pauses with Escape or gamepad Start. The menu should navigate with confirm/cancel while the ship cannot move or fire.
local Input = require("demi.input")
local Hud = require("demi.hud")
local PauseMenu = {}
function PauseMenu:on_open()
Input.disable_context("gameplay")
Input.enable_context("menu")
Hud.set_visible("pause_panel", true)
end
function PauseMenu:on_update(dt)
if Input.pressed("ui_cancel") then
self:on_close()
end
end
function PauseMenu:on_close()
Hud.set_visible("pause_panel", false)
Input.disable_context("menu")
Input.enable_context("gameplay")
end
return PauseMenu
Walkthrough: on_open turns the gameplay context off so move and jump immediately read as released, then turns menu on so ui_ok and ui_cancel work. on_close reverses both calls. No gameplay script is touched — the controller simply sees zeros while paused.
Assigning gamepads to players
On shared screens each pad drives one player slot. Assignment is explicit:
local count = Input.gamepad_count() -- number of connected pads
Input.assign_gamepad(1, 0) -- device index 1 feeds player 0
Input.gamepad_count()returns how many gamepads are connected right now, as a number.Input.assign_gamepad(device, player)maps a device index (0-based) to a local player slot and returnstruewhen the assignment applied.- Bindings and actions with a
playerfield only respond to the device assigned to that slot.
Rebinding an action
Rebinding replaces the physical input that feeds one binding of an action for one player.
Input.rebind("jump", 1, "key:e", 0) -- action, binding index (one-based), new control, player
- First argument is the action name.
- Second is the one-based index of the binding to replace within that action.
- Third is the new control name — the same strings used in the project, like
key:e,gamepad:west, orvirtual:jump. - Fourth is the player slot the remap applies to (optional).
- Returns
trueon success plus an error string on failure. From the next frame,jumpresponds to the new control.
A small options screen can capture a key and apply it:
function Options:on_ui_event(e)
if e.type == "press" and e.id == "rebind_jump" then
self.waiting_for = "jump"
end
end
function Options:on_update(dt)
if not self.waiting_for then return end
if Input.key_pressed("e") then
Input.rebind(self.waiting_for, 1, "key:e", 0)
Input.save_bindings("input/bindings.json")
self.waiting_for = nil
end
end
The example waits for the E key with Input.key_pressed, rebinds the chosen action, and immediately saves. Use raw key helpers only while capturing a remap; gameplay keeps using actions.
Saving and loading bindings
Remaps should survive quitting. Persistence writes a small JSON file of current bindings.
local Application = require("demi.application")
local root = Application.user_data_path()
Input.save_bindings("input/bindings.json")
local ok = Input.load_bindings("input/bindings.json") -- true when loaded
Input.save_bindings(path)writes the current bindings and returnstrueon success plus an error string.Input.load_bindings(path)reads them back and returnstrueon success;falseplus an error string when the file is missing or unreadable.- Relative paths resolve below
Application.user_data_path()— the per-player writable directory, never the game install. A plain name likeinput/bindings.jsonbecomes<user_data>/input/bindings.json. Absolute paths are used as supplied, and relative paths are rejected until user-data storage is configured.
Load at startup and save when the player leaves the options screen:
function Game:on_create()
Input.load_bindings("input/bindings.json")
end
The saved JSON mirrors the project’s binding shape: action names, their bindings, and the player slot each applies to. A typical file looks like this:
{
"actions": {
"jump": {
"bindings": [
{ "input": "key:e" },
{ "input": "gamepad:south" }
]
},
"move": {
"bindings": [
{ "input": "gamepad:stick:left", "deadzone": 0.18 }
]
}
}
}
Read it as: “jump is now fed by key E and the gamepad south button; move uses the left stick.” You can restore the authored defaults by deleting the file — load_bindings returns false and the project’s input.actions remain in force.
Replay and contexts
Relative binding-save paths are resolved below Application.user_data_path(), never the current working directory, and are rejected before user-data storage is configured. Absolute paths are used as supplied. Initialization enables gameplay; raw key queries bypass the context filter.
Input replay re-plays recorded action frames through the same context filter as live play. Two consequences:
- A recorded action only applies while its context is enabled; disabling a context suppresses its recorded values rather than pausing replay time.
- When a recorded action is active it takes precedence over physical bindings, so a fixture runs the same on any machine.
Replay rows carry resolved values, not context metadata. The filter uses the current authored action definition. An undefined recorded action is neutral in filtered gameplay queries; unfiltered native replay consumers can still read it. An empty enabled-context set disables all named actions.
demi run \
--input-replay fixtures/smoke.replay.json
This runs Skyforge with a recorded input stream. Keep the project’s fixed timestep equal to the fixture’s so frames line up. More on the test workflow: Testing & debugging.
Next
- Actions explained — action types and queries.
- Input bindings — binding fields and presets.
- Input — the overview page.