Skip to content

Lua Macro Guide

Nicholas van de Walle edited this page Jun 21, 2026 · 1 revision

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.


Introduction

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.


Getting Started

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.


Sending commands & moving

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 TOGGLE

Examples:

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 moving

You 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.


Playing sounds & alerts

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.


Reading game state

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 (09), 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 clears

Number slots (09) 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)

Hotkeys & mouse clicks

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-click

Hotkeys 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)

Typed-text commands

cl.bind_text(word, fn) -- run fn when you type `word` and press return

This 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)

Pausing & timing

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)

The text log buffer

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.


Reacting to incoming messages

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 type

Your 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).


Registering macros

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.


Login & logout

cl.on_login(fn)          -- runs once each time you log in
cl.on_logout(fn)         -- runs once when you log out / disconnect

Example — 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)

Tips & tricks

  • State persists between handler runs. Use file-level local variables 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:.


Lua language basics

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
end

Handy stdlib: string.find / string.format / string.lower / string.sub, math.random / math.floor, table.insert. nil is false-y; 0 and "" are true.

Clone this wiki locally