AI chat

can you write a script that will comprehensively map wasd and my mouse as a controller output

luau
--[[
  rebind: min_sdk=3.11.0
  rebind: name=Keyboard and Mouse to Joypad
  rebind: description=WASD drives the left stick, the mouse the right stick, and the mouse buttons pull the triggers.
  rebind: hardware_only=true
  rebind: mouse_block=true
  rebind: permissions=none
]]

-- needs hardware mode and a Rebind Link whose USB profile includes a joypad;
-- add Joypad under Devices > Flash identity before attaching a controller

local cfg = rb.ui.schema({
  toggle = rb.ui.keybind("F9", { label = "Engage / release" }),
  gain = rb.ui.slider(0.35, { min = 0.05, max = 1, step = 0.05, label = "Mouse gain" }),
  smoothing = rb.ui.slider(55, { min = 0, max = 90, suffix = "%", label = "Stick smoothing" }),
})

local MOVE_KEYS = {
  W = { x = 0, y = -1 },
  S = { x = 0, y = 1 },
  A = { x = -1, y = 0 },
  D = { x = 1, y = 0 },
}

local IDLE_MS = 250 -- hand the axes back to a real controller after this

local engaged = false
local held = {}
local trigger = { LT = 0, RT = 0 }
local acc_x, acc_y = 0, 0
local rsx, rsy = 0, 0
local idle_ms, driving = 0, false

local function clamp(v, lo, hi)
  return math.max(lo, math.min(hi, v))
end

local function release_all()
  if driving then
    rb.hid.joypad_reset() -- axes a script sets stay set until this
    driving = false
  end
  acc_x, acc_y = 0, 0
  rsx, rsy = 0, 0
  trigger.LT, trigger.RT = 0, 0
  held = {}
  idle_ms = 0
end

rb.bind(cfg.toggle, function()
  engaged = not engaged
  if engaged then
    idle_ms = 0
    rb.ui.notify("Joypad output on", "info")
  else
    release_all()
    rb.ui.notify("Joypad output off", "info")
  end
end)

for key in MOVE_KEYS do
  rb.bind(key, function()
    held[key] = true
  end, {
    when = function()
      return engaged
    end,
    release = function()
      held[key] = nil
    end,
  })
end

-- mouse buttons pull the triggers
local function click_trigger(button, axis)
  rb.bind(button, function()
    trigger[axis] = 1
  end, {
    when = function()
      return engaged
    end,
    release = function()
      trigger[axis] = 0
    end,
  })
end

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

rb.on("move", function(dx, dy)
  if not engaged then
    return true -- cursor works normally
  end
  acc_x += dx
  acc_y += dy
  return false
end)

rb.on("tick", function(dt_ms)
  if not engaged or dt_ms <= 0 then
    return
  end

  -- right stick: smoothed mouse velocity, so fast flicks are not clipped
  local keep = cfg.smoothing / 100
  local blend = 1 - keep
  rsx = rsx * keep + clamp(acc_x / dt_ms * cfg.gain, -1, 1) * blend
  rsy = rsy * keep + clamp(acc_y / dt_ms * cfg.gain, -1, 1) * blend
  acc_x, acc_y = 0, 0
  if math.abs(rsx) < 0.02 then
    rsx = 0
  end
  if math.abs(rsy) < 0.02 then
    rsy = 0
  end

  -- left stick: WASD, diagonals scaled so they are not faster
  local lx, ly = 0, 0
  for key, dir in MOVE_KEYS do
    if held[key] then
      lx += dir.x
      ly += dir.y
    end
  end
  if lx ~= 0 and ly ~= 0 then
    lx, ly = lx * 0.7071, ly * 0.7071
  end

  local active = rsx ~= 0 or rsy ~= 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
    if driving then
      rb.hid.joypad_reset()
      driving = false
    end
    return
  end

  driving = true
  rb.hid.axis("RightX", rsx)
  rb.hid.axis("RightY", rsy)
  rb.hid.axis("LeftX", lx)
  rb.hid.axis("LeftY", ly)
  rb.hid.axis("LT", trigger.LT)
  rb.hid.axis("RT", trigger.RT)
end)

rb.on("blur", release_all)
rb.on("stop", release_all)

How it works

  • F9 engages. Until then every key, click, and movement passes through untouched, so the script does not take over your desktop when it loads. F9 again releases and resets the joypad.
  • WASD → left stick. Each held key contributes -1/0/+1 per axis; diagonals are multiplied by 0.7071 so moving diagonally isn't faster than straight. They're gated on when, so with the script released WASD types normally.
  • Mouse → right stick. The move handler accumulates deltas and swallows them (return false); the tick handler turns that accumulation into a stick deflection proportional to velocity, then applies an exponential moving average. gain sets how far the stick travels per pixel per ms, smoothing sets how much of the previous value is kept (higher = steadier, slightly laggier). Leftover sub-pixel movement stays in the accumulator, so nothing is lost.
  • Clicks → triggers. Mouse1 fully pulls RT, Mouse2 pulls LT.
  • Idle hand-back. After 250 ms with no virtual input, rb.hid.joypad_reset() drops what the script set, so a real controller has its axes back. An axis a script sets otherwise stays set, which is why the reset matters.
  • Cleanup. blur and stop reset the joypad and clear held state, so no stick is left pushed when you release or the script stops.

To add buttons, add remaps next to the binds — rb.bind.remap("Space", "PadA"), rb.bind.remap("Q", "PadLB"), and so on. Pad names are PadA, PadB, PadX, PadY, PadLB, PadRB, PadLT, PadRT, PadUp…PadRight. Left Ctrl + Left Alt + K still kills every script and releases held input.

Learn more