-
Notifications
You must be signed in to change notification settings - Fork 0
Lua Macro Guide
The full walkthrough of TrowlLord's Lua macro engine. This mirrors the in-app guide (Macros window → Lua Guide); for a one-page API reference see Lua API Cheatsheet, and for why Lua exists alongside the classic language see Why Lua Macros.
Lua macros let you automate Clan Lord with the full Lua 5.4 language — variables, functions, loops, tables, and math — instead of the classic macro syntax.
Everything you do talks to the game through one global table called cl. You send
commands, read your state, bind keys and clicks, and react to the text log through cl.*
functions.
Lua macros live in their own flat folder (separate from classic macros). All .lua
files in that folder are loaded into a single shared Lua world, so a file can define helper
functions that other files use. The two macro systems never share state.
A macro file usually just registers things when it loads — hotkeys, clicks, typed-text commands, a login handler, named triggers — and those handlers run later when the event happens.
Make a new Lua script (the "New Lua script" button in the Macros window), then try:
cl.bind_key("f1", function()
cl.send("/think Hello from Lua!")
end)Save the file. Pressing F1 in the game now sends that command.
Reload after editing: the engine reloads automatically when you save and when you close the Macros window, or click "Reload macros".
A script's top level runs once at load. Put your cl.bind_* / cl.on_* / cl.register
calls there. The functions you pass run later, each time the event fires.
cl.send(text) -- send a command/chat line to the server
cl.message(text) -- show a local note in your text log (never sent)
cl.move(dir, speed) -- move: dir = "n","ne","e",...,"nw" or "stop"
-- speed = "walk" (default) or "run"
cl.bubble_fade(on) -- fade speech/thought bubbles out over time: true/false
-- to set, or no argument to TOGGLEExamples:
cl.send("/think hi") -- same as typing it and pressing return
cl.send("/useitem Sunstone")
cl.move("n", "run") -- run north
cl.move("stop") -- stop movingYou can call cl.send as many times as you like; commands are queued and sent to the server
one per frame (the server only accepts one per frame), so a burst paces itself automatically.
cl.play_sound plays a sound effect from the game's CL_Sounds file by its numeric id —
handy as an audible alert when something happens (you can see sound ids in the Sounds
window).
cl.play_sound(id) -- play at your normal sound volume
cl.play_sound(id, volume) -- volume like "70%" (a string), or a number
cl.play_sound(id, volume, bypass) -- bypass=true → an alert (see below)The optional volume is a string such as "70%" (or a number — a value of 1 or less is
read as a 0..1 fraction, otherwise as a 0..100 percent).
If the third argument is true, the sound plays at exactly that volume, ignoring your
character's sound-volume slider and the master/background mute — so it's reliably audible
as a loud alert even if you've turned game sounds down or off:
-- ding loudly when someone falls, no matter your sound settings
cl.on_text("falls to", function()
cl.play_sound(121, "100%", true) -- bypass volume + mute: a real alert
end)
-- a quieter, settings-respecting blip when you get a thought
cl.on_text({ category = "thought" }, function()
cl.play_sound(33, "60%") -- scaled by your volume slider; mute silences it
end)Without bypass, the sound behaves like any other game effect: your volume slider scales it
and muting silences it.
These read your live state (they return nil if unknown):
cl.my_name() -- your full name
cl.my_simple_name() -- name with only letters/digits
cl.selected_player() -- your currently selected/targeted player
cl.selected_item() -- the highlighted inventory item
cl.my_item(slot) -- the item equipped in a slot (or nil)
cl.num_item(n) -- inventory number slot n (0-9): a table, or nil
cl.frame() -- current server frame number (5 per second)slot is one of: right, left, head, neck, finger, forehead, shoulder, arms,
gloves, coat, cloak, torso, waist, legs, feet, hands.
cl.num_item(n) returns { name = <string>, equipped = <bool>, right_hand = <bool> } for the
inventory Alt+digit slot n (0–9), or nil if that slot is empty.
Set the selection from a macro (the counterparts to the readers above):
cl.select(name) -- select/target a player by full or partial name; no arg clears
cl.select_item(name) -- select an inventory item by name; no arg clearsNumber slots (0–9) are the inventory Alt+digit assignments. Act on them directly:
cl.equip_num(3) -- equip slot 3, exactly like pressing Alt+3
cl.use_num(3) -- "use" slot 3 (hand-aware: equips it first if needed)Example — only cast if your finger holds a Shieldstone:
cl.register("shield_check", function()
local f = cl.my_item("finger")
if f and string.find(f:lower(), "shieldstone") then
cl.send("/use Shieldstone")
else
cl.message("No shieldstone equipped.")
end
end)Example — use number slot 1 only if it isn't already in hand (skip a redundant equip):
cl.bind_key("f4", function()
local it = cl.num_item(1)
if it and not it.right_hand then
cl.use_num(1)
end
end)cl.bind_key(spec, fn) -- spec like "f1", "control-f2", "shift-f5"
cl.bind_click(button, fn) -- button is 1..5; the handler gets (player, button)
cl.bind_double_click(button, fn) -- same, but fires on a double-clickHotkeys fire on the function keys (and other named keys) with optional modifiers (control / shift / option / command).
Click handlers receive the clicked player's name (or nil if you clicked empty ground) and
the button number. A double-click fires the single-click handler on the first press and the
double-click handler on the second — bind both if you want a click and a double-click to do
different things.
Example — right-click (button 2) a player to select them:
cl.bind_click(2, function(player, button)
if player then
cl.select(player)
end
end)Example — double-right-click a player to lock healing onto them:
cl.bind_double_click(2, function(player, button)
if player then
cl.send("/useitem caduceus " .. player)
end
end)Example — F3 toggles a flag:
local sneaking = false
cl.bind_key("f3", function()
sneaking = not sneaking
cl.message(sneaking and "Sneaking on" or "Sneaking off")
end)cl.bind_text(word, fn) -- run fn when you type `word` and press returnThis is the Lua version of a classic "expression" macro (like typing t hello to think):
when the line you send starts with word, your function runs instead and the line is
not sent to the server. The rest of the line (everything after the word) is passed to
your function as a string.
cl.bind_text("ff", function(rest)
cl.send("/think flap flap: " .. rest)
end)Now typing ff hello runs that and sends /think flap flap: hello; typing just ff passes
an empty string. Matching is exact and case-sensitive on the first word. If a classic
macro of the same name exists it wins (classic is checked first); the most recent
cl.bind_text for a word overrides any earlier one.
A toggle is just a file-level variable. To toggle reliably you usually also need to watch the
text log, so the server cancelling your state (death, out of mana) resets the toggle —
otherwise the next press is out of sync. Example: a shape-shift toggle on ff that re-arms
when the server says you returned to normal:
local shaped = false
cl.bind_text("ff", function()
if shaped then
cl.send("/useitem belt /return")
else
cl.send("/useitem belt /shape flying foxweir")
end
shaped = not shaped
end)
cl.on_login(function() -- a watcher that runs every frame (see below)
local last = 0
while true do
for _, line in ipairs(cl.textlog()) do
if line.frame > last
and string.find(line.text, "You return to your normal form", 1, true) then
last = line.frame
shaped = false -- server returned us → next ff shapechanges again
end
end
cl.pause(1)
end
end)cl.pause(frames) waits for a number of server frames, exactly like the classic macro
pause. There are 5 frames per second, so cl.pause(5) waits ~1 second.
Pause only works inside a handler (it runs as a coroutine). While paused, the rest of the game keeps running and other macros keep working.
Example — a timed three-step sequence:
cl.bind_key("f4", function()
cl.send("/pose wave")
cl.pause(5) -- wait ~1 second
cl.send("/say Hello!")
cl.pause(10) -- wait ~2 seconds
cl.send("/pose bow")
end)Important: never write an infinite loop without a pause (e.g. while true do end) — it would
freeze the client. The engine will abort a handler that runs too long without pausing, but
you should cl.pause(1) inside long loops to yield each frame:
cl.register("follow", function()
for i = 1, 100 do
cl.move("n", "walk")
cl.pause(1) -- yield one frame each step
end
cl.move("stop")
end)Macros that react to chat often miss messages or fire twice on a repeat. To make this
reliable, the engine keeps the last 10 text-log lines as (frame, text) pairs. Each line is
stamped with the frame it arrived on (5 frames/second), so the same text arriving twice
in quick succession has two different frame stamps.
cl.textlog() -- array of { frame=, text= }, oldest first (up to 10)
cl.textlog_latest() -- just the most recent line's text (or nil)Pattern — act on a new line once, even if the text repeats:
local last_seen = 0
cl.register("watch", function()
for _, line in ipairs(cl.textlog()) do
if line.frame > last_seen and string.find(line.text, "falls to") then
last_seen = line.frame
cl.send("/think Someone fell!")
end
end
end)Because you compare the frame stamp (not the text), the same message arriving again a moment later has a newer frame and triggers again — and you never double-fire on the same line.
Instead of polling cl.textlog() yourself, you can register a handler that fires once for
each new text-log line as it arrives:
cl.on_text(fn) -- fn(text, frame, category) for EVERY new line
cl.on_text(pattern, fn) -- only lines matching a Lua pattern
cl.on_text({ pattern=, category= }, fn) -- filter on pattern and/or message typeYour handler gets three arguments: the line text, the server frame it arrived on (same
stamps as cl.textlog / cl.frame), and a category string for the message type. Unlike
cl.bind_text (which reacts to what you type and only the last handler for a word wins),
cl.on_text reacts to incoming messages and every matching handler fires — you can
watch for several things at once.
category is one of: speech, myspeech, thought, logon, logoff, share, host,
obit, who, info, bard, timestamp, error, default.
Example — announce when anyone falls (the pattern filter does the matching for you):
cl.on_text("falls to", function(text)
cl.send("/think someone fell: " .. text)
end)Example — only react to thoughts that mention you, and reply after a beat:
cl.on_text({ category = "thought" }, function(text)
if string.find(text, cl.my_simple_name() or "") then
cl.pause(2) -- handlers are coroutines: pause works here
cl.send("/thinkto last yes?")
end
end)The pattern is a Lua string pattern (the same kind string.find uses), so "%d+ coins"
matches a number followed by " coins". For a plain substring just write the literal text;
a malformed pattern simply never matches (it isn't a fatal error).
cl.register(name, fn) exposes a macro by name so other systems can run it — most
importantly the gamepad. Registered names appear in the Controller page, where you can map a
controller button to any of them.
cl.register("heal_self", function()
cl.send("/use Healing Crystal")
end)Now open the Controller page and pick "heal_self" from the dropdown next to, say, the "A" button. Pressing A on the gamepad runs the macro.
You can register as many as you like; give them clear names — that's what you'll see in the mapping dropdowns.
cl.on_login(fn) -- runs once each time you log in
cl.on_logout(fn) -- runs once when you log out / disconnectExample — greet yourself and set things up on login:
cl.on_login(function()
cl.message("Welcome back, " .. (cl.my_name() or "adventurer") .. "!")
end)
cl.on_logout(function()
cl.message("Safe travels.")
end)-
State persists between handler runs. Use file-level
localvariables to remember things (toggles, counters, the last frame you reacted to):local count = 0 cl.bind_key("f6", function() count = count + 1; cl.message("hits: "..count) end)
-
Prefer comparing text-log frame stamps over text, so repeats fire correctly and you never double-handle the same line (see "The text log buffer").
-
cl.pause(1)inside loops keeps the client responsive — one iteration per frame. -
Build commands with
..(string concat):cl.send("/give " .. player .. " 10"). -
Case-insensitive item checks:
string.find(item:lower(), "sunstone"). -
Keep helpers in one file and handlers in another — all Lua files share one world, so a helper defined anywhere is callable everywhere.
-
math.random()is seeded for you each session, so randomized emotes vary. -
If a macro misbehaves,
cl.message(...)is your print/debug — it shows locally and is never sent to the server. -
Errors (syntax or runtime) are reported in your text log, prefixed with
Lua:.
A 60-second refresher (see the full manual: https://www.lua.org/manual/5.4/):
-- comments start with two dashes
local x = 5 -- variables (use 'local')
local s = "text" -- strings; concatenate with ..
if x > 3 and s ~= "" then -- and / or / not ; not-equal is ~=
-- ...
elseif x == 0 then
else
end
for i = 1, 10 do end -- numeric for
local t = { 1, 2, 3 } -- tables (arrays start at index 1)
for i, v in ipairs(t) do end-- iterate an array
local m = { hp = 10 } -- tables as maps
m.hp = m.hp - 1
local function add(a, b) -- functions are values
return a + b
endHandy stdlib: string.find / string.format / string.lower / string.sub,
math.random / math.floor, table.insert. nil is false-y; 0 and "" are true.