Quick navigation

Go to

Input & controls

Key mappings

Register rebindable actions and handle press and hold callbacks.

7 min read1,593 wordsMarkdown
On this page8 sections

Register a named action, default key and Lua callback with RegisterKeyMapping. Players can rebind it under Pause β†’ Settings β†’ KEY BINDINGS. Bindings persist across servers and launcher restarts.

Developers coming from FiveM will recognize the call. Open77 has no client command bus, so the mapping runs a Lua callback the resource supplies directly, rather than a registered command string.

A binding is observe-only: pressing its key fires the action but never hides the key from the game, and it is suppressed whenever a page owns the keyboard (chat open, the pause menu open, a rebind in progress), so typing never triggers an action.

Permission

Every entry point requires the input.actions permission β€” the same one that gates Open77.input.isDown:

resource "flashlight"
version "1.0.0"

client_script "client/main.lua"

permissions { "input.actions" }

Declare a mapping

RegisterKeyMapping is a bare global, mirrored as Open77.input.registerKeyMapping. The positional form is the common case:

-- RegisterKeyMapping(id, name, defaultKey, onPressed [, onReleased])
RegisterKeyMapping("flashlight", "Toggle flashlight", "F", function()
    toggleFlashlight()
end)

The callback receives the mapping id as its only argument. RegisterKeyMapping returns true, "<effectiveKey>" on success β€” the effective key is the player's saved rebind if there is one, otherwise the default β€” or false, "<reason>".

Re-registering the same id replaces the previous entry (reload-safe) and keeps the player's rebind.

Press and hold

By default the action fires once on the key-down edge. Supply a release callback β€” or set hold in the table form β€” to receive the key-up edge as well. Use the table form when you need hold explicitly:

Open77.input.registerKeyMapping({
    id = "sprint",
    name = "Hold to sprint",
    key = "SHIFT",           -- `defaultKey` is accepted as an alias
    hold = true,
    onPressed  = function() startSprint() end,
    onReleased = function() stopSprint() end,
})

In the positional form, passing a fifth argument (the release callback) implies hold mode.

Key names

Keys use the same vocabulary as Open77.input.isDown, normalized to upper case:

  • a single alphanumeric β€” A–Z, 0–9
  • The French AZERTY number row: &, Γ©, ", ', (, -, Γ¨, _, Γ§, Γ , ), =. Common punctuation is accepted too: + [] {} ; : , . / \\ and ` ~ ! ? @ # $ % ^ * < > | ΓΉ Β².
  • F1–F12
  • SPACE, ENTER/RETURN, TAB, SHIFT, CTRL/CONTROL, ALT, CAPSLOCK, BACKSPACE, INSERT, DELETE, HOME, END, PAGEUP, PAGEDOWN, and the four arrow keys UP, DOWN, LEFT, RIGHT.

The pause page's capture, the Lua layer, and the native dispatch all agree on this set, so a key chosen in the rebinding UI maps to exactly what a resource registered.

Letters are normalized to uppercase; accented characters and punctuation are preserved. These are logical keys on the player's current Windows layout, not US physical labels: on French AZERTY, Γ© and 2 refer to the same top-row key. No Shift chord is required. The menu displays the captured character, including when CEF provides no event.code. Use UTF-8 Lua files, for example RegisterKeyMapping("slot2", "Second slot", "Γ©", callback).

Only keyboard keys can be bound. A mapping's key is persisted, listed in the pause menu and polled by the engine's own dispatcher, and that dispatcher reads keys. Mouse buttons and gamepad controls are readable β€” see the next section β€” but they are not bindable, and the vocabulary was deliberately left alone so that a key saved in keybinds.json can never name a control nothing polls.

Reading a device directly

Open77.input also reads the mouse, the wheel, a gamepad and the cursor. Same table, same input.actions permission: a mouse button is the same kind of read as a key, and a second permission string would only mean every menu manifest grows a line.

Call Answers
Open77.input.isDown(control) A key, a mouse button or a gamepad button. boolean, or false, reason.
Open77.input.axis(name) A stick, a trigger, the cursor, or a cumulative counter. number, or nil, reason.
Open77.input.pointer() x, y, source β€” the pointer as 0..1 across the image, overlay-aware.
Open77.input.devices() Every mouse and gamepad control, and whether this machine can answer for it.
Open77.input.isCaptured() Whether a WebUI owns the keyboard.

Control names

Group Names
Mouse buttons mouse1/mouseLeft, mouse2/mouseRight, mouse3/mouseMiddle, mouse4, mouse5
Mouse axes mouseX, mouseY, mouseDeltaX, mouseDeltaY, mouseWheel, mouseWheelX
Gamepad buttons padA, padB, padX, padY, padUp, padDown, padLeft, padRight, padStart, padBack, padLeftShoulder (padLB), padRightShoulder (padRB), padLeftThumb, padRightThumb
Gamepad axes padLeftStickX/Y, padRightStickX/Y, padLeftTrigger, padRightTrigger β€” each also spelled without the pad prefix (leftStickX, rightTrigger, …)

Names are case-insensitive. Open77.input.devices() is the authoritative list for the machine the resource is running on, and it is the list to build a settings screen from rather than the table above.

These are physical reads

Everything here reports the device, not the game's interpretation of it. It does not know the player's Cyberpunk key bindings, it does not know whether the game or a menu is currently consuming the control, and it does not know about in-game controller remapping. In FiveM's vocabulary that makes every one of these the GetDisabledControlNormal flavour rather than the GetControlNormal one.

To get the enabled flavour, combine it with the two questions Open77 can answer about who owns the input:

local function gameplayInput(control)
    if Open77.input.isCaptured() then return false end        -- a WebUI has the keyboard
    if Open77.session.isMenuOpen() then return false end      -- some modal owns the screen
    return Open77.input.isDown(control) == true
end

Gamepad reads are also zero β€” and buttons false β€” while the game window is not in the foreground. The keyboard already behaved that way; this only extends the rule to the pad.

A missing device is named, never answered zero

An unplugged gamepad refuses by name. This matters more than it looks: an axis that always reads zero cannot be told from a stick held at centre, so a menu built on a silent zero looks broken rather than unsupported.

local turn, reason = Open77.input.axis("padRightStickX")
if turn == nil then
    -- reason == "gamepad_not_connected" on a machine with no pad
    turn = 0
end

Asking for an axis by isDown, or a button by axis, is a real mistake and each is named: control_is_an_axis and control_is_a_button. An unknown name is unsupported_action_key.

Gamepad support is XInput, slot 0. Controllers that present themselves as XInput devices β€” the Xbox pads, and a DualSense or DualShock through Steam Input or DS4Windows β€” are read; one that does not is invisible and reports as not connected. Sticks come back with XInput's own dead zones removed and the remainder rescaled, so a stick at rest reads exactly 0 and at the rim exactly 1; stick Y is positive up.

The wheel and the motion counters are cumulative

mouseWheel, mouseWheelX, mouseDeltaX and mouseDeltaY count from the moment the client started and are not consumed by reading them. Difference two samples:

local previous = Open77.input.axis("mouseWheel") or 0
CreateThread(function()
    while true do
        Wait(0)
        local now = Open77.input.axis("mouseWheel") or 0
        if now ~= previous then
            scrollList(now - previous)   -- positive is away from the player
            previous = now
        end
    end
end)

A consume-on-read wheel would be shorter to use and wrong: two resources reading it would each receive part of the same scroll, and neither could tell.

The pointer, and which space it is in

Open77.input.pointer() returns x, y as 0..1 across the rendered image, origin top left, plus a source. That is the same frame Open77.camera.project answers in and Open77.camera.unproject consumes, so hit-testing a projected world point is a subtraction:

local x, y = Open77.input.pointer()
local target = Open77.camera.project({ x = 100.0, y = 220.0, z = 15.0 })
if x and target and target.onScreen then
    local dx, dy = x - target.x, y - target.y
    if dx * dx + dy * dy < 0.02 * 0.02 then showTooltip() end
end

Coordinates are normalized rather than pixel-based. DPI scaling can make the window's client rectangle differ from the render buffer; normalized values use a consistent coordinate space.

source says which regime produced it:

source Meaning
overlay A WebUI surface owns the pointer. This is Open77's own virtual cursor, the one CEF and the drawn pointer share β€” the only meaningful position while a page is up.
system Nobody owns it, so this is the OS cursor mapped into the game's client area.

During ordinary gameplay Cyberpunk recentres the OS cursor every frame, so system reads near 0.5, 0.5. That is where the cursor is, not a failed read.

Rebinding UI

The pause menu's KEY BINDINGS tab lists every registered action across every running resource and rebinds them. It is only a viewer: it reads the registry and writes back through the same management API any resource may call.

This includes both bundled client resources and resources downloaded from the current server. Callbacks stay in their owning resource; the pause menu can list, rebind and reset them across the two client layers. Stopped/disconnected resources disappear from the list, while their saved choices are retained for the next session. There are no demo bindings in the shipped pause menu.

Call Effect
Open77.input.mappings() Array of every registered mapping: { resource, id, name, key, defaultKey, hold, rebound }.
Open77.input.rebind(resource, id, key) Set and persist a new key for any mapping. Returns true, key or false, reason.
Open77.input.reset(resource, id) Restore a mapping to its default key and drop the saved override. Returns true, defaultKey.
Open77.input.keyFor(id) Effective key of one of this resource's own mappings, or nil.
Open77.input.unregisterKeyMapping(id) Remove a mapping this resource declared. Returns a boolean.

The engine raises the local event open77:keybinds:changed (no arguments) after any register, rebind, reset, or unregister, so a viewer can reload its list:

AddEventHandler("open77:keybinds:changed", function()
    -- refresh a custom keybind display
end)

Registration reference

Field Meaning
id Unique within the resource. Letters, numbers, _, ., :, -, up to 64 characters.
name Human label shown in the KEY BINDINGS tab. Defaults to id; capped at 96 characters.
key Default key from the vocabulary above. defaultKey is an accepted alias.
hold When true, onReleased fires on key-up in addition to onPressed on key-down.
onPressed Called on the key-down edge with the mapping id.
onReleased Called on the key-up edge (hold mode only) with the mapping id.

A resource may register up to 64 mappings. Bindings never fire while another WebUI owns keyboard focus, and pressing a mapped key does not stop the game from also seeing it β€” choose keys that do not clash with an in-world action, or let players rebind.

Persistence

Rebinds are stored in a single machine-global file, …/storage/keybinds.json, deliberately outside the per-server KVP layout so a rebind follows the player to every server and survives a relaunch. The engine owns and validates the format; a corrupt file degrades to defaults rather than refusing bindings. A mapping inherits its saved key at registration time, so the player's choice is applied the moment the resource declares the action on the next session.

If writing the preferences fails, rebind/reset returns false, "keybind_save_failed" and keeps the previous effective key. Updating one client layer does not discard the other's overrides.

// For agents & LLMsthis page as Markdownllms.txtllms-full.txt

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try