(Windows) I replaced 10 years of AutoHotkey scripts with a single Lua file.
I've been running AutoHotkey for about ten years. Long enough that my scripts folder outlived three PCs. Thirteen .ahk files that my hands depend on: arrow-key rebinds for games that won't let you change controls, chat macros and stash-tab wheel navigation for Path of Exile 2, a quick-melee combo for Marvel Rivals, window snapping into thirds on Win+Alt+arrows, a terminal toggle on backtick, tab switching on Ctrl+S/Ctrl+D, a text expansion, CapsLock mapped to Escape. The kind of setup you stop thinking about because it's part of how you use the machine.
Last week I replaced all of it with one Rebind script. I didn't even write the script. RebindGPT converted my old .ahk files.
Rebind is a free app that remaps your keyboard with Luau scripts. Same mental model as AHK — bind a key, check a condition, send output — but one typed language and one runtime. No v1-versus-v2 split.
The before state
If you've kept an AutoHotkey setup alive for years, you know the shape of the problem. It's never one script. It's a folder of them, each with its own tray icon, each added to the Startup folder by hand on every new machine.
Mine were split across both languages. The window-snapping script was v1, full of comma syntax and %var% deref rules I had to re-learn every time I touched it:
; WindowsMove.ahk (v1) — cycle the active window through thirds
MoveWindow(XP, WP) {
WinGetActiveTitle, WinTitle
WinGetPos, X, Y, WinWidth, WinHeight, %WinTitle%
WinGetPos,,, tbW, tbH, ahk_class Shell_TrayWnd
XNew := (A_ScreenWidth * XP / 100)
WNew := (A_ScreenWidth * WP / 100)
HNew := (A_ScreenHeight - tbH)
WinRestore, %WinTitle%
WinMove, %WinTitle%,, %XNew%, 0, %WNew%, %HNew%
}
#!Left::MoveCycle(-1)
#!Right::MoveCycle(1)The game rebinds were newer, so those were v2. This one exists because a certain action game hard-codes WASD and I play on arrows:
#Requires AutoHotkey v2.0
#HotIf WinActive("ahk_exe eldenring.exe")
~Up::w
~Down::s
~Left::a
~Right::d
XButton1::Space
#HotIfThen there were the per-game quality-of-life scripts, and those collected warts of their own. The Path of Exile 2 one grew a config.ini, label-based subroutines, and dynamic hotkey registration — three layers of plumbing for what is, at heart, "press F5, type /hideout in chat":
; poe2.ahk (v1) — chat macros, stash tabs, and config.ini plumbing
IniRead, THANK_MESSAGE, config.ini, Settings, thank_message, ty4t
IniRead, HIDEOUT_KEY, config.ini, Settings, hideout_key, {F5}
#IfWinActive Path of Exile 2
Hotkey, %HIDEOUT_KEY%, Hideout
Hideout:
SendInput {Enter}/hideout{Enter}
return
^WheelUp::Send, {Left} ; previous stash tab
^WheelDown::Send, {Right} ; next stash tab
~$^LButton:: ; hold Ctrl+Click = rapid clicks
While GetKeyState("LButton", "P") and GetKeyState("Ctrl", "P")
{
Click
Sleep, 5
}
return
#IfWinActiveThe Marvel Rivals one bound quick melee to Shift+wheel. Simple idea, but the file opened with a block that relaunched the script as admin, plus three RegWrite lines to stop the Shift+wheel spam from popping the Windows Sticky Keys dialog mid-match. Every game script re-invented that kind of prologue.
The community calls AHK v2 "practically a new programming language," and they're right. The official converter got my v1 scripts maybe 80% of the way, and I burned evenings on the rest before giving up and just running both runtimes side by side. Which means two file associations, two tray icons per script family, and remembering two syntaxes forever. Even ChatGPT kept answering my v2 questions with v1 code.
AutoHotkey is genuinely good software. But ten years in, my "setup" was a pile of files in two dialects that I mostly hoped I'd never have to open again.
The after state
One Luau file, one section per old script, and it reads top to bottom. Here's the game-rebind section — the whole v2 script above becomes:
-- Arrow keys drive WASD in games that refuse to rebind them.
local GAMES = {
["eldenring.exe"] = true,
["nightreign.exe"] = true,
["sekiro.exe"] = true,
}
for from, to in pairs({ Up = "W", Down = "S", Left = "A", Right = "D", Mouse4 = "Space" }) do
Bind(from, {
when = function() return GAMES[front()] end,
action = function() HID.Down(to) return false end,
release = function() HID.Up(to) end,
})
endAdd a game to the table, done. No per-script #HotIf blocks, no separate file per game.
The Path of Exile 2 chat macros collapsed into one helper and three binds:
-- Open chat, type a line, send it (call from a coroutine).
local function chat(line)
HID.Press("Enter")
Sleep(25)
HID.Type(line)
HID.Press("Enter")
end
Bind("F5", function() -- go to hideout
if front() ~= POE2 then return true end
Run(function() chat("/hideout") end)
return false
end)And the config.ini is gone. The thank-you message it stored is now a text field in the Rebind panel — same place as the terminal setting — instead of a file I had to keep next to the script and parse with IniRead.
The window snapping came over too, and got shorter:
-- Win+Alt+Left / Right cycles the active window through thirds:
-- 1/3 left → 1/2 left → 2/3 left → 2/3 right → 1/2 right → 1/3 right
Bind("LWin+LAlt+Left", function() snap(-1) return false end)
Bind("LWin+LAlt+Right", function() snap(1) return false end)(Rebind is a free download if you want to poke at it while you read. The full script is at the bottom.)
I didn't write this script
Here's the part that actually surprised me. I pasted my .ahk files — the v1 ones and the v2 ones, as-is — into RebindGPT. It sorted out which dialect each file was in, converted the lot into one Luau project, and kept the behavior. Including the subtle stuff I would have gotten wrong on the first pass, like the fact that held physical modifiers merge into injected events:
-- Ctrl+S = previous tab. Physical Ctrl is already held and merges
-- into injected events, so we only send the delta keys.
Bind("LCtrl+S", function()
if not TABBED[front()] then return true end
HID.Combo("LShift+Tab") -- lands as Ctrl+Shift+Tab
return false
end)It also asked me one question I wouldn't have thought to answer up front: what my taskbar height was, so the snapped windows wouldn't cover it. That number is now a comment in the script instead of folklore in my head.
The full script
Everything below is what runs on my machine today. The terminal and the Ctrl+; text expansion surface as UI controls in the Rebind panel, so the script itself stays generic:
The full script (~240 lines)
-- rebind: name=taky-ahk
-- rebind: min_sdk=3.2.1
-- rebind: description=AutoHotkey parity — game rebinds and QoL macros, window snapping, app launch and toggle, tab navigation, text expansion.
-- One section per old .ahk file. Reads top to bottom.
-- ═══════════════════════════════════════════════════════════════════════════════
-- Config (surfaces as fields in the Rebind panel)
-- ═══════════════════════════════════════════════════════════════════════════════
local cfg = UI.Schema({
terminal = UI.Text("Windows Terminal", {
label = "Terminal Window Title",
tooltip = "Window title of your terminal (e.g. Windows Terminal, Alacritty).",
maxLength = 60,
}),
termCmd = UI.Text("wt.exe", {
label = "Terminal Command",
tooltip = "Command used to launch the terminal when it isn't running.",
maxLength = 60,
}),
snippet = UI.Text("your-snippet-here", {
label = "Snippet",
tooltip = "Text inserted by Ctrl+;",
maxLength = 128,
}),
thankMsg = UI.Text("ty4t", {
label = "Trade Thanks",
tooltip = "Message sent before leaving a party in Path of Exile 2 (F7).",
maxLength = 60,
}),
})
-- ═══════════════════════════════════════════════════════════════════════════════
-- Shared helpers: modifier state and the foreground process
-- ═══════════════════════════════════════════════════════════════════════════════
local function ctrl() return Input.IsDown("LCtrl") or Input.IsDown("RCtrl") end
local function shift() return Input.IsDown("LShift") or Input.IsDown("RShift") end
local function alt() return Input.IsDown("LAlt") or Input.IsDown("RAlt") end
local function win() return Input.IsDown("LWin") or Input.IsDown("RWin") end
local function bare() return not (ctrl() or shift() or alt() or win()) end
-- Foreground process name, checked on demand (not polled).
local function front()
local w = System.Window()
return w and w.process or ""
end
-- ═══════════════════════════════════════════════════════════════════════════════
-- Game rebinds: arrows drive WASD where the game won't let you rebind
-- ═══════════════════════════════════════════════════════════════════════════════
local GAMES = {
["eldenring.exe"] = true,
["nightreign.exe"] = true,
["sekiro.exe"] = true,
}
for from, to in pairs({ Up = "W", Down = "S", Left = "A", Right = "D", Mouse4 = "Space" }) do
Bind(from, {
when = function() return GAMES[front()] end,
action = function() HID.Down(to) return false end,
release = function() HID.Up(to) end,
})
end
-- ═══════════════════════════════════════════════════════════════════════════════
-- Path of Exile 2: chat macros, item linking, rapid clicking
-- ═══════════════════════════════════════════════════════════════════════════════
local POE2 = "PathOfExile.exe"
-- Open chat, type a line, send it (call from a coroutine).
local function chat(line)
HID.Press("Enter")
Sleep(25)
HID.Type(line)
HID.Press("Enter")
end
Bind("F5", function() -- go to hideout
if front() ~= POE2 then return true end
Run(function() chat("/hideout") end)
return false
end)
Bind("F6", function() -- leave party
if front() ~= POE2 then return true end
Run(function() chat("/leave") end)
return false
end)
Bind("F7", function() -- thank for the trade, then leave
if front() ~= POE2 then return true end
Run(function()
chat(cfg.thankMsg)
Sleep(25)
chat("/leave")
end)
return false
end)
Bind("F11", function() -- link the item under the cursor (Ctrl+Alt+Click)
if front() ~= POE2 then return true end
HID.Combo("RCtrl+RAlt+Mouse1")
return false
end)
-- Hold Ctrl+Click: rapid clicks for fast stash and inventory dumps.
Bind("Mouse1", function()
if not (ctrl() and front() == POE2) then return true end
Run(function()
while Input.IsDown("Mouse1") and ctrl() do
HID.Press("Mouse1")
Sleep(5)
end
end)
return true -- pass the original click through, like AHK's `~` prefix
end)
-- ═══════════════════════════════════════════════════════════════════════════════
-- Scroll wheel: Rivals quick melee + PoE2 stash tabs share the one scroll hook
-- ═══════════════════════════════════════════════════════════════════════════════
local RIVALS = "Marvel-Win64-Shipping.exe"
-- delta > 0 is wheel-up. Return true to pass the scroll through untouched.
function OnScroll(delta)
-- Marvel Rivals: Shift+wheel = quick melee (tap E) into a follow-up
if shift() and front() == RIVALS then
Run(function()
HID.Press("E", 10)
Sleep(10)
if delta > 0 then HID.Press("Mouse1") else HID.Scroll(1) end
end)
return false
end
-- Path of Exile 2: Ctrl+wheel = previous / next stash tab
if ctrl() and front() == POE2 then
HID.Press(delta > 0 and "Left" or "Right")
return false
end
return true
end
-- ═══════════════════════════════════════════════════════════════════════════════
-- Window snapping: Win+Alt+Left / Right cycles the active window through thirds
-- ═══════════════════════════════════════════════════════════════════════════════
local STOPS = { {0, 1/3}, {0, 1/2}, {0, 2/3}, {1/3, 2/3}, {1/2, 1/2}, {2/3, 1/3} }
local cycle = 0
local function snap(step)
cycle = (cycle + step) % #STOPS
local sw, sh = System.Screen()
local s = STOPS[cycle + 1]
Window.Move(0, math.floor(sw * s[1]), 0, math.floor(sw * s[2]), sh - 48) -- 48 = taskbar
end
Bind("LWin+LAlt+Left", function() snap(-1) return false end)
Bind("LWin+LAlt+Right", function() snap(1) return false end)
-- ═══════════════════════════════════════════════════════════════════════════════
-- Tab navigation: Ctrl+S / Ctrl+D = previous / next tab in browsers and editors
-- ═══════════════════════════════════════════════════════════════════════════════
local TABBED = {
["chrome.exe"] = true,
["msedge.exe"] = true,
["firefox.exe"] = true,
["Code.exe"] = true,
}
-- Physical Ctrl is already held and merges into injected events,
-- so we only send the delta keys.
Bind("LCtrl+S", function()
if not TABBED[front()] then return true end
HID.Combo("LShift+Tab") -- lands as Ctrl+Shift+Tab
return false
end)
Bind("LCtrl+D", function()
if not TABBED[front()] then return true end
HID.Combo("Tab") -- lands as Ctrl+Tab
return false
end)
-- ═══════════════════════════════════════════════════════════════════════════════
-- Terminal toggle + app launching
-- ═══════════════════════════════════════════════════════════════════════════════
-- Bare backtick summons the terminal. Launches it if it isn't running.
Bind("Grave", function()
if not bare() then return true end
local h = Window.Find(cfg.terminal)
if not h then
System.ExecDetached(cfg.termCmd)
elseif Window.IsActive(h) then
Window.Minimize(h)
else
Window.Activate(h)
end
return false
end)
-- Win+N: notepad, wherever I am.
Bind("LWin+N", function()
System.ExecDetached("notepad.exe")
return false
end)
-- ═══════════════════════════════════════════════════════════════════════════════
-- Text expansion + the small stuff
-- ═══════════════════════════════════════════════════════════════════════════════
-- Ctrl+; types the configured snippet once the modifiers clear
-- (held modifiers would merge into the injected text).
Bind("Semicolon", function()
if not ctrl() then return true end
Run(function()
while not bare() do Sleep(5) end
HID.Type(cfg.snippet)
end)
return false
end)
Bind.Remap("CapsLock", "Escape")
-- ═══════════════════════════════════════════════════════════════════════════════
-- Startup
-- ═══════════════════════════════════════════════════════════════════════════════
function OnStart()
-- Shift+wheel spam used to pop the Windows Sticky Keys dialog mid-match.
-- Same registry writes the old Marvel Rivals script did at startup.
Registry.Write([[HKCU\Control Panel\Accessibility\StickyKeys]], "REG_DWORD", "Flags", 506)
Registry.Write([[HKCU\Control Panel\Accessibility\FilterKeys]], "REG_DWORD", "Flags", 122)
Registry.Write([[HKCU\Control Panel\Accessibility\ToggleKeys]], "REG_DWORD", "Flags", 58)
Log.Info(string.format("taky-ahk ready — terminal=%s front=%s", cfg.terminal, front()))
endWhat carried over, one for one
- Game rebinds. Arrows to WASD, mouse side button to dodge, only while a listed game is in front
- Path of Exile 2 chat macros. F5 hideout, F6 leave party, F7 thank-and-leave with the message set in the panel, F11 links the item under the cursor
- Stash and inventory speed. Ctrl+wheel flips stash tabs, holding Ctrl+click rapid-fires clicks
- Marvel Rivals quick melee. Shift+wheel taps E into a follow-up attack
- The Sticky Keys guard. The registry tweaks the old script did at startup still happen at startup
- Window snapping. Win+Alt+Left/Right cycles the active window through third, half, and two-thirds layouts
- Terminal toggle. Bare backtick summons my terminal, hides it, or launches it if it's not running
- App launching. Win+N opens Notepad from anywhere
- Tab navigation. Ctrl+S and Ctrl+D across browsers and editors
- Text expansion and CapsLock to Escape. The small ones that add up
Why this beats the old setup
It's one language. Luau, typed, with real functions and tables. There is no v1 script I'm scared to touch and no v2 script that needs different muscle memory. There will not be a v3 rewrite of my config.
It reads top to bottom. One section per old script — games, PoE2, the wheel, windows, tabs, terminal — each self-contained. No label subroutines, no auto-execute section, no jumping around the file to find where a hotkey ends up.
It's one file. The whole setup diffs in git, greps, and copies to a new machine as a file instead of a folder, a runtime install, and a row of tray icons. The config.ini sidecar files are gone too — anything I used to tune by editing an ini is a labeled field in the Rebind panel.
It's conversational to extend. Yesterday I wanted the side mouse button mapped to jump in one more game. One sentence to RebindGPT, one line added to a table.
Ten years of .ahk files, now one file an AI converted in one sitting. If you keep your AutoHotkey folder out of habit like I did, it's worth an afternoon. Grab Rebind, paste your scripts into RebindGPT, and see what comes back.