> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rebind.gg/llms.txt
> Use this file to discover all available pages before exploring further.

# Controllers

> Read a game controller, drive Rebind Link's joypad, and mix keyboard, mouse, and controller input, with complete Luau scripts.

Every recipe on this page needs hardware mode and a Rebind Link whose USB
profile includes a joypad; see [Change the USB identity](/getting-started/hardware-setup#change-the-usb-identity).
Attach the controller in **Peripherals** the way you
attach a keyboard or mouse; see [Attach your devices](/getting-started/hardware-setup#attach-your-devices).
With no script running, the controller passes straight through to the Link's
joypad, so games keep seeing it.

Scripts treat a controller like any other input:

* **Buttons are keys.** `PadA`, `PadLB`, `PadUp` and the rest arrive as `down`
  and `up` events and work with `rb.bind`, `rb.bind.remap`, `rb.input.is_down`
  and `rb.hid.down`. A controller button sent through `rb.hid` goes to the
  Link's joypad; a keyboard key or mouse button goes to the keyboard or mouse.
* **Sticks and triggers are axes.** An `rb.on("axis", fn)` handler sees each
  change; return `false` to keep that value off the joypad. `rb.input.axis(name)`
  reads the current value, and `rb.hid.axis(name, value)` sets one on the joypad.
* **The joypad is shared.** A button a script presses adds to the controller's
  buttons. An axis a script sets wins over the controller's until
  `rb.hid.joypad_reset()`, which Rebind also runs when the script stops.

Button and axis names, ranges, supported controllers, and limits are in
[Controllers](/reference/sdk-reference#controllers). Press
**Left Ctrl + Left Alt + K** (**Left Control + Left Option + K** on macOS) at any time to stop every running script and
release held input.

On Windows, the Link's joypad shows up in the game controller panel: press
Win+R, run `joy.cpl`, select the joypad, and click **Properties** to watch its
buttons and axes while a script runs.

## Watch a controller

Observer events see every input without taking any of it, so the controller
keeps working while this script logs what it does. The first input from each
controller logs its name, vid:pid, and `kind`.

```luau theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
--[[
  rebind: min_sdk=3.11.0
  rebind: name=Controller Monitor
  rebind: version=1.0.0
  rebind: hardware_only=true
  rebind: permissions=none
]]

local seen = {}

local function note(device)
  if device and not seen[device.id] then
    seen[device.id] = true
    rb.log.info(string.format("%s (%s:%s, %s)", device.name, device.vid, device.pid, device.kind))
  end
end

rb.on("input_down", function(key, device)
  if string.sub(key, 1, 3) == "Pad" then
    note(device)
    rb.log.info("down", key)
  end
end)

rb.on("input_up", function(key, duration_ms)
  if string.sub(key, 1, 3) == "Pad" then
    rb.log.info(string.format("up %s after %d ms", key, duration_ms))
  end
end)

rb.on("axis", function(axis, value, device)
  note(device)
  if math.abs(value) > 0.5 then
    rb.log.info(string.format("%s %+.2f", axis, value))
  end
  -- no return value: the axis reaches the joypad unchanged
end)
```

Watch the output with `rebind logs`.

## Buttons as keyboard keys

`rb.bind.remap` holds the target for as long as the button is held, so a
controller types, navigates, and holds modifiers. The remapped buttons stop
reaching the joypad; everything else still does.

```luau theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
--[[
  rebind: min_sdk=3.11.0
  rebind: name=Controller Keys
  rebind: version=1.0.0
  rebind: hardware_only=true
  rebind: permissions=none
]]

rb.bind.remap("PadA", "Enter")
rb.bind.remap("PadB", "Escape")
rb.bind.remap("PadUp", "Up")
rb.bind.remap("PadDown", "Down")
rb.bind.remap("PadLeft", "Left")
rb.bind.remap("PadRight", "Right")
rb.bind.remap("PadLB", "LCtrl+LShift+Tab")
rb.bind.remap("PadRB", "LCtrl+Tab")
```

The bumpers switch browser tabs; A and B confirm and cancel dialogs.

## A stick as the mouse

The left stick moves the cursor and the face buttons click. The `axis` handler
keeps the left stick off the joypad; `rb.input.axis` still reads it on every
tick. Holding RB slows the cursor for precise work.

```luau theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
--[[
  rebind: min_sdk=3.11.0
  rebind: name=Stick Mouse
  rebind: version=1.0.0
  rebind: hardware_only=true
  rebind: permissions=none
  rebind: tick_rate=125
]]

local SPEED = 1400 -- pixels per second at full tilt

rb.on("axis", function(axis)
  if axis == "LeftX" or axis == "LeftY" then
    return false
  end
end)

rb.bind.remap("PadA", "Mouse1")
rb.bind.remap("PadB", "Mouse2")
rb.bind("PadRB", function() end) -- taken, so it only slows the cursor

local carry_x, carry_y = 0, 0

rb.on("tick", function(dt_ms)
  local speed = if rb.input.is_down("PadRB") then SPEED / 4 else SPEED
  local dt = math.min(dt_ms, 50) / 1000
  -- squaring keeps small deflections fine and full tilt fast
  local x, y = rb.input.axis("LeftX"), rb.input.axis("LeftY")
  carry_x += x * math.abs(x) * speed * dt
  carry_y += y * math.abs(y) * speed * dt
  local dx = if carry_x < 0 then math.ceil(carry_x) else math.floor(carry_x)
  local dy = if carry_y < 0 then math.ceil(carry_y) else math.floor(carry_y)
  carry_x -= dx
  carry_y -= dy
  if dx ~= 0 or dy ~= 0 then
    rb.hid.move(dx, dy)
  end
end)
```

`tick_rate=125` runs the tick every 8 ms, which is smooth enough for a cursor.

## Remap the joypad

A game sees the joypad, so remapping it remaps the controller for every game at
once. Here A and B swap, the left stick's vertical axis inverts, and the right
trigger gets a softer curve: half pressed reads a quarter, fully pressed still
reads full.

```luau theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
--[[
  rebind: min_sdk=3.11.0
  rebind: name=Joypad Remap
  rebind: version=1.0.0
  rebind: hardware_only=true
  rebind: permissions=none
]]

rb.bind.remap("PadA", "PadB")
rb.bind.remap("PadB", "PadA")

rb.on("axis", function(axis, value)
  if axis == "LeftY" then
    rb.hid.axis("LeftY", -value)
    return false
  elseif axis == "RT" then
    rb.hid.axis("RT", value * value)
    return false
  end
end)
```

Stopping the script returns the joypad to the plain controller.

## Keyboard and mouse as a controller

The reverse direction: the mouse aims with the right stick, WASD moves the left
stick, and clicks pull the triggers. `mouse_block=true` lets the `move` handler
keep mouse movement from reaching the computer while the mouse is captured.
F9 releases the mouse so the cursor works again, and F10 stops the script.

```luau theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
--[[
  rebind: min_sdk=3.11.0
  rebind: name=Keyboard and Mouse to Joypad
  rebind: version=1.0.0
  rebind: hardware_only=true
  rebind: mouse_block=true
  rebind: permissions=none
]]

local GAIN = 0.35 -- right stick deflection per mouse count per ms
local SMOOTH = 0.55 -- share of the last stick value kept each tick
local IDLE_MS = 250 -- hand the axes back to a real controller after this

rb.bind.remap("Space", "PadA")
rb.bind.remap("F", "PadB")
rb.bind.remap("R", "PadX")
rb.bind.remap("T", "PadY")
rb.bind.remap("Q", "PadLB")
rb.bind.remap("E", "PadRB")

local captured = true
local held = {}
local trigger = { LT = 0, RT = 0 }
local acc_x, acc_y, stick_x, stick_y = 0, 0, 0, 0
local idle_ms, driving = 0, false

local function clamp(v)
  return math.max(-1, math.min(1, v))
end

local function button_held()
  for _, key in ipairs({ "Space", "F", "R", "T", "Q", "E" }) do
    if rb.input.is_down(key) then
      return true
    end
  end
  return false
end

rb.on("move", function(dx, dy)
  if not captured then
    return true
  end
  acc_x += dx
  acc_y += dy
  return false
end)

rb.on("tick", function(dt_ms)
  if dt_ms <= 0 then
    return
  end
  stick_x = stick_x * SMOOTH + clamp(acc_x / dt_ms * GAIN) * (1 - SMOOTH)
  stick_y = stick_y * SMOOTH + clamp(acc_y / dt_ms * GAIN) * (1 - SMOOTH)
  acc_x, acc_y = 0, 0
  if math.abs(stick_x) < 0.02 then
    stick_x = 0
  end
  if math.abs(stick_y) < 0.02 then
    stick_y = 0
  end

  local lx = (held.D and 1 or 0) - (held.A and 1 or 0)
  local ly = (held.S and 1 or 0) - (held.W and 1 or 0)
  if lx ~= 0 and ly ~= 0 then
    lx, ly = lx * 0.7071, ly * 0.7071
  end

  local active = stick_x ~= 0 or stick_y ~= 0 or lx ~= 0 or ly ~= 0
    or trigger.LT > 0 or trigger.RT > 0
  idle_ms = if active then 0 else idle_ms + dt_ms
  if idle_ms >= IDLE_MS then
    -- an axis a script sets stays set, so give the axes back to the
    -- controller. joypad_reset also releases the script's buttons, so
    -- wait while a button key is held.
    if driving and not button_held() then
      rb.hid.joypad_reset()
      driving = false
    end
    return
  end
  driving = true
  rb.hid.axis("RightX", stick_x)
  rb.hid.axis("RightY", stick_y)
  rb.hid.axis("LeftX", lx)
  rb.hid.axis("LeftY", ly)
  rb.hid.axis("LT", trigger.LT)
  rb.hid.axis("RT", trigger.RT)
end)

for _, key in ipairs({ "W", "A", "S", "D" }) do
  rb.bind(key, function()
    held[key] = true
  end, {
    release = function()
      held[key] = nil
    end,
  })
end

-- while the mouse is released, `when` lets clicks through as normal clicks
local function click(button, axis)
  rb.bind(button, function()
    trigger[axis] = 1
  end, {
    when = function()
      return captured
    end,
    release = function()
      trigger[axis] = 0
    end,
  })
end

click("Mouse1", "RT")
click("Mouse2", "LT")

rb.bind("F9", function()
  captured = not captured
  stick_x, stick_y, acc_x, acc_y = 0, 0, 0, 0
  trigger.LT, trigger.RT = 0, 0
  rb.ui.notify(if captured then "Mouse drives the right stick" else "Mouse released")
end)

rb.bind("F10", function()
  rb.script.exit("F10 pressed")
end)
```

A real controller keeps working beside it. While a key or the mouse is moving
an axis, the script's value wins; once both have been still for `IDLE_MS`, the
controller has the axes again. To aim faster, raise `GAIN`.

## Rapid fire

Holding X taps the joypad's X about twelve times a second until you let go.
`rb.run` repeats the press in a coroutine, and the bind's `release` stops it.

```luau theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
--[[
  rebind: min_sdk=3.11.0
  rebind: name=Rapid Fire
  rebind: version=1.0.0
  rebind: hardware_only=true
  rebind: permissions=none
]]

local task = nil

rb.bind("PadX", function()
  if task then
    return
  end
  task = rb.run(function()
    while true do
      rb.hid.press("PadX", 40)
      rb.sleep(40)
    end
  end)
end, {
  release = function()
    if task then
      task:stop()
      task = nil
    end
    rb.hid.up("PadX")
  end,
})
```

To rapid-fire a keyboard key instead, change `"PadX"` in `rb.hid.press` and
`rb.hid.up` to that key's name.

## A combo that plays a motion

Pressing both bumpers together plays a quarter-circle motion on the joypad
(down, down and right, right) and then X. Every button still reaches the joypad
as normal, because the script only observes.

```luau theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
--[[
  rebind: min_sdk=3.11.0
  rebind: name=Bumper Combo
  rebind: version=1.0.0
  rebind: hardware_only=true
  rebind: permissions=none
]]

local STEP_MS = 50
local playing = nil

rb.on("input_down", function(key)
  if key ~= "PadLB" and key ~= "PadRB" then
    return
  end
  if not (rb.input.is_down("PadLB") and rb.input.is_down("PadRB")) then
    return
  end
  if playing and playing:is_running() then
    return
  end
  playing = rb.run(function()
    rb.hid.press("PadDown", STEP_MS)
    rb.hid.down("PadDown")
    rb.hid.down("PadRight")
    rb.sleep(STEP_MS)
    rb.hid.up("PadDown")
    rb.sleep(STEP_MS)
    rb.hid.up("PadRight")
    rb.hid.press("PadX", 80)
  end)
end)
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.