DEMIENGINEDOCUMENTATION Everything is free
Input · Create

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) returns true when the group is on, else false.

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 returns true when the assignment applied.
  • Bindings and actions with a player field 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, or virtual:jump.
  • Fourth is the player slot the remap applies to (optional).
  • Returns true on success plus an error string on failure. From the next frame, jump responds 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 returns true on success plus an error string.
  • Input.load_bindings(path) reads them back and returns true on success; false plus 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 like input/bindings.json becomes <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