RLS Studios
ProjectsPatreonCommunityDocsAbout
Join Patreon
BeamNG Modding Docs

Guides

GE Hook CatalogInput Bindings & Keybinding SystemDebugging Your ModData Persistence & SavingVehicle Engine Documentation MapVehicle Engine Tag IndexGE Documentation MapDocumentation Tag IndexPhone UI SystemAngular Overlay Pattern (Mod UI)

Reference

UI

Resources

BeamNG Game Engine Lua Cheat SheetGE Developer RecipesMCP Server Setup

// RLS.STUDIOS=true

Premium Mods for BeamNG.drive. Career systems, custom vehicles, and immersive gameplay experiences.

Index

HomeProjectsPatreon

Socials

DiscordPatreon (RLS)Patreon (Vehicles)

© 2026 RLS Studios. All rights reserved.

Modding since 2024

GuidesReference

Input Bindings & Keybinding System

How to read, write, and capture keybindings — data structure, Lua and UI APIs, input capture flow, and critical cleanup for mod deactivation.

Reference for the GE-level keybinding system: how bindings are stored, read, written, and captured. This covers core_input_bindings (Lua) and the controls.js Pinia store (UI).

See Also

  • Cross-VM Comms - How Lua and UI data flows
  • UI Bridge - Lua to UI communication
  • Anti-Patterns - Common mistakes to avoid

Data Structure

Bindings are organized per device. The canonical data lives in core_input_bindings.bindings (Lua) and is pushed to the UI via InputBindingsChanged event.

core_input_bindings.bindings = [
  {
    devname: "keyboard0",
    contents: {
      guid: "...",
      devicetype: "...",
      name: "...",
      vidpid: "...",
      displayName: "...",
      bindings: [
        { action: "openPhone", control: "p", player: 0, ... },
        { action: "toggleParkingBrake", control: "x", player: 0, ... },
        ...
      ]
    }
  },
  {
    devname: "mouse0",
    contents: { ... }
  },
  ...
]

Binding Entry Fields

FieldTypeDescription
actionstringAction name (e.g. "openPhone")
controlstringRaw control name - "p", NOT "keyboard_p"
playernumberPlayer index (usually 0)
lockTypenumberDefault 1
linearitynumberDefault 1
isInvertedbooleanDefault false
deadzoneRestingnumberDefault 0
deadzoneEndnumberDefault 0
ffbobjectForce feedback config

⚠️ Critical: Control strings are raw device names - "p", "button0", "x" - never prefixed with device type like "keyboard_p".


Reading Bindings

Iterate core_input_bindings.bindings → each device's contents.bindings[] looking for action match.

Lua Side

local function findBinding(actionName)
  if not core_input_bindings or not core_input_bindings.bindings then return nil end
  for _, device in ipairs(core_input_bindings.bindings) do
    if device.contents and device.contents.bindings then
      for _, b in ipairs(device.contents.bindings) do
        if b.action == actionName and b.control and b.control ~= "" then
          return b, device
        end
      end
    end
  end
  return nil
end

UI Side (Vue/controls.js)

The controls.js Pinia store caches all bindings. Use:

Controls.findBindingForAction(actionName, devName?, useLastDeviceOrder?)

This reads from the in-memory store - it does not call Lua.


Writing / Saving Bindings

Pattern: modify device.contents.bindings array in memory, then save.

Adding a Binding (Lua)

local device = nil
for _, d in ipairs(core_input_bindings.bindings) do
  if d.devname == deviceName then device = d; break end
end

table.insert(device.contents.bindings, {
  action = "openPhone",
  control = "p",     -- raw control, no prefix
  player = 0,
})

core_input_bindings.saveBindingsToDisk(device.contents)

Adding a Binding (JS)

const device = bindings.value.find(b => b.devname === devname)
device.contents.bindings.push({
  ...bindingTemplate.value,
  control: rawControl,
  action: actionName,
})
lua.extensions.core_input_bindings.saveBindingsToDisk(device.contents)

⚠️ Key Gotchas

  1. Pass device.contents, NOT the device wrapper. saveBindingsToDisk expects the inner contents object with guid, devicetype, name, bindings[], etc.

    -- WRONG: saves garbage / wipes bindings
    core_input_bindings.saveBindingsToDisk(targetDevice)
    
    -- CORRECT:
    core_input_bindings.saveBindingsToDisk(targetDevice.contents)
  2. Saves ALL bindings for one device, not just one binding. It replaces the entire binding file for that device. Other devices are untouched.

  3. saveBindingsToDisk handles engine rebinding automatically. Vanilla never calls am:bind() directly - the save function triggers the engine to pick up the new bindings.

  4. Call core_input_bindings.notifyUI() after saving to push fresh data to the UI:

    core_input_bindings.notifyUI("binding changed")

Capturing Input (Recording a Key Press)

To let the user press a key and capture what they pressed, follow this exact sequence:

Start Capture

// 1. Prevent bound actions from firing during capture
lua.ActionMap.enableBindingCapturing(true)

// 2. Prevent CEF from eating keystrokes
lua.setCEFTyping(true)

// 3. Forward raw input events to JS
lua.WinInput.setForwardRawEvents(true)

// 4. Listen for input
events.on("RawInputChanged", listener)

Handle RawInputChanged

The event data contains:

{
  devName: "keyboard0",   // device identifier
  control: "p",           // raw control name
  controlType: "...",
  value: 1,               // 1 = pressed, 0 = released
  direction: "..."
}

Validate by waiting for a press+release cycle on the same key.

Stop Capture (reverse order)

lua.ActionMap.enableBindingCapturing(false)
lua.WinInput.setForwardRawEvents(false)
lua.setCEFTyping(false)
events.off("RawInputChanged", listener)

Refreshing UI Data

core_input_bindings.notifyUI("reason string")

This triggers the InputBindingsChanged event on the UI side, which delivers the full bindings data to the controls store's _processBindingsData().


Custom Actions & Mod Lifecycle

Registering Custom Actions

Custom actions defined via JSON files (e.g. phone.json in your mod's input_actions/ folder) are NOT automatically rescanned when mods load. You must force a rescan:

extensions.reload("core_input_actions")

Call this on mod startup (e.g. in onExtensionLoaded) to ensure your custom action is registered before any code tries to look it up.

⚠️ bindingsLegend.lua Fatal Crash

The vanilla bindingsLegend.lua iterates ALL saved bindings and looks up action metadata via the action registry. If it encounters a binding for an action that doesn't exist (e.g. "openPhone" after your mod is deactivated), it crashes with a nil actionInfo error.

This is a fatal crash on game startup - the user cannot recover without manually editing their binding files.

Prevention requires TWO things:

  1. Force rescan on startup: Call extensions.reload("core_input_actions") in your mod's init so the action JSON is loaded before bindingsLegend runs.
  2. Clean up bindings on mod deactivation: Remove any custom action bindings from saved data when your mod unloads (see below).

Mod Deactivation Cleanup (CRITICAL)

If your mod adds custom bindings, you MUST remove them when the mod deactivates. Otherwise the game crashes on next boot because bindingsLegend.lua finds bindings for actions that no longer exist.

local function removeCustomBinding(actionName)
  if not core_input_bindings or not core_input_bindings.bindings then return end
  for _, device in ipairs(core_input_bindings.bindings) do
    if device.contents and device.contents.bindings then
      local newBindings = {}
      local changed = false
      for _, b in ipairs(device.contents.bindings) do
        if b.action == actionName then
          changed = true
        else
          table.insert(newBindings, b)
        end
      end
      if changed then
        device.contents.bindings = newBindings
        core_input_bindings.saveBindingsToDisk(device.contents)
      end
    end
  end
end

-- In your mod's unload/deactivation hook:
local function onExtensionUnloaded()
  removeCustomBinding("openPhone")
  -- Also reload core_input_actions to drop the now-unmounted action JSON
  extensions.reload("core_input_actions")
end

Custom Action JSON Loading Order

core_input_actions may not have your custom action registered when your extension code first runs. If you need to call actionToCommands() or any function that looks up action metadata, always check first and force reload if needed:

local cmds = core_input_actions.actionToCommands("myAction")
if not cmds then
  extensions.reload("core_input_actions")
  cmds = core_input_actions.actionToCommands("myAction")
end

Anti-Patterns

❌ Don't✅ Do Instead
Prefix control strings: "keyboard_p"Use raw control: "p"
Pass device wrapper to saveBindingsToDiskPass device.contents
Call am:bind() manuallyLet saveBindingsToDisk handle engine rebinding
Read bindings via custom Lua functionsIterate core_input_bindings.bindings structure
Forget to call notifyUI() after saveAlways notify so UI stays in sync

Complete Example: Add a Custom Keybinding

local function setCustomBinding(actionName, controlString, deviceName)
  deviceName = deviceName or "keyboard0"
  
  if not core_input_bindings or not core_input_bindings.bindings then
    log("E", "bindings", "core_input_bindings not available")
    return false
  end
  
  -- Find device
  local device = nil
  for _, d in ipairs(core_input_bindings.bindings) do
    if d.devname == deviceName then device = d; break end
  end
  if not device then return false end
  
  -- Remove existing binding for this action on this device
  local newBindings = {}
  for _, b in ipairs(device.contents.bindings) do
    if b.action ~= actionName then
      table.insert(newBindings, b)
    end
  end
  
  -- Add new binding
  table.insert(newBindings, {
    action = actionName,
    control = controlString,  -- raw: "p", not "keyboard_p"
    player = 0,
  })
  
  device.contents.bindings = newBindings
  core_input_bindings.saveBindingsToDisk(device.contents)
  core_input_bindings.notifyUI("custom binding updated")
  return true
end

GE Hook Catalog

Every GE hook with signatures and descriptions — lifecycle, per-frame, vehicle, physics, world, input, and file system hooks.

Debugging Your Mod

Practical debugging techniques — logging, data inspection, console commands, and solutions to the most common Lua errors.

On this page

See AlsoData StructureBinding Entry FieldsReading BindingsLua SideUI Side (Vue/controls.js)Writing / Saving BindingsAdding a Binding (Lua)Adding a Binding (JS)⚠️ Key GotchasCapturing Input (Recording a Key Press)Start CaptureHandle RawInputChangedStop Capture (reverse order)Refreshing UI DataCustom Actions & Mod LifecycleRegistering Custom Actions⚠️ bindingsLegend.lua Fatal CrashMod Deactivation Cleanup (CRITICAL)Custom Action JSON Loading OrderAnti-PatternsComplete Example: Add a Custom Keybinding