Skip to content

Why Lua Macros

Nicholas van de Walle edited this page Jun 21, 2026 · 2 revisions

Why Lua Macros?

TrowlLord ships two macro systems. The first is a faithful re-implementation of Clan Lord's classic macro language — the same "go" { ... }, set, goto, pause syntax you've used for years, byte-for-byte compatible so your existing macros keep working. The second is a full Lua 5.4 engine.

This page explains why the Lua engine exists: the classic language is wonderful for "send this command when I press this key," but it hits a wall the moment you need to manipulate text, keep a list of things, store a yes/no flag, or reuse logic. Those walls are exactly what Lua removes.

Both systems run side by side. You don't have to choose — use classic for your reflexes and reach for Lua when a macro starts fighting you.


The classic language has no…

Feature Classic Lua
String functions (length, substring, replace, split, case) ❌ only .word[n] / .letter[n] ✅ full string.* library
Real arrays / lists / maps ❌ flat string keys like p1_name ✅ tables ({}), ipairs, pairs
Booleans you can store ❌ truth only exists inside if true / false are values
Compound conditions (and / or / not) ❌ one comparison per if if a and not b then
while / for loops ❌ only label / goto while, for, repeat
Functions with arguments & return values call shares one flat scope function f(x) return … end
Floating-point math, bitwise ops, math.* ❌ 32-bit ints only ✅ full math.*
Local scope / closures ❌ dynamic, everything global-ish local, closures

Below are real macros that were painful (or impossible) in the classic language and are now a few clean lines of Lua.


1. String manipulation

Classic: no way to split, search, lowercase, or slice text

The classic language gives you @text.word[n] and @text.letter[n] and nothing else. There is no length, no substring, no find, no replace, no case conversion. "Does this text log string contain 'sunstone', ignoring case?" simply can't be expressed — the closest is the comparison operators, whose substring behavior is asymmetric and case-sensitivity flips depending on which operator you pick (> is case-insensitive, >= is case-sensitive…). It's a well-known footgun.

// Classic: "does my forehead item contain sunstone?" — fragile and case-sensitive
"check"
{
  set item @my.forehead
  if item >= "sunstone"          // case-SENSITIVE substring test; misses "Sunstone"
    "/think yes\r"
  end if
}

Lua: the whole string library

cl.bind_text("check", function()
    local item = cl.my_item("right") or ""
    if string.find(item:lower(), "sunstone", 1, true) then   -- case-insensitive, reliable
        cl.send("/think yes")
    end
end)

A harder one — parse a "give" command like /give Stinky 50 into pieces. In classic you'd chain .word[0], .word[1], .word[2]… and hope the spacing is exactly right; there's no way to validate that the amount is a number or to rejoin the tail.

cl.bind_text("give", function(rest)
    -- pull out "<name> <amount>" with a pattern; reject anything malformed
    local who, amount = rest:match("^(%S+)%s+(%d+)$")
    if not who then
        cl.message("usage: give <name> <amount>")
        return
    end
    cl.send(string.format("/give %s %s coins", who, amount))
end)

string.match, string.format, :lower(), :gsub() — none of these have any classic equivalent.


2. Arrays and lists

Classic: there are no arrays — only var[i] flat string keys

myarr[2][3] isn't a 2-D array; it's literally the string key "myarr[2][3]". You can't iterate it, you can't ask how long it is, and the index must be a literal or a single variable — never an expression like myarr[i+1]. To "loop over a list" you hand-roll a label/goto counter and pray.

// Classic: greet three friends — by hand, no iteration
"greet"
{
  set f[0] "Stinky"
  set f[1] "Drablak"
  set f[2] "Karina"
  set i 0
  label loop
  if i < 3
    "/yell hi " f[i] "\r"
    set i + 1
    goto loop
  end if
}

Lua: real tables you can iterate, grow, sort

local friends = { "Stinky", "Drablak", "Karina" }

cl.bind_text("greet", function()
    for _, name in ipairs(friends) do
        cl.send("/say Hi " .. name)
    end
end)

And because it's a real list you can do things classic can't express at all — build it up at runtime, keep a rolling history, dedupe, sort:

-- remember the last 5 people who fell, newest first, no duplicates
local fallen = {}

cl.register("watch_falls", function()
    for _, line in ipairs(cl.textlog()) do
        local who = line.text:match("(%S+) falls to a")
        if who then
            -- drop an existing entry, then push to front
            for i = #fallen, 1, -1 do
                if fallen[i] == who then table.remove(fallen, i) end
            end
            table.insert(fallen, 1, who)
            fallen[6] = nil   -- cap at 5
        end
    end
end)

3. Booleans and compound logic

Classic: truth can't be stored, and if takes ONE comparison

There is no boolean type. The only "true" the classic language understands is the literal string "true" for a handful of @env flags. You can't write set ready (hp > 50), and you can't write if low_health and not in_town — every if is a single comparison, so compound conditions become nested ladders.

// Classic: "heal only if hurt AND not poisoned" — nested, awkward
"heal"
{
  if hurt == "true"
    if poisoned == "false"
      "/useitem caduceus\r"
    end if
  end if
}

Lua: real booleans, and / or / not

local hurt = false
local poisoned = false

cl.bind_text("heal", function()
    if hurt and not poisoned then
        cl.send("/useitem caduceus")
    end
end)

A clean toggle — trivial in Lua, genuinely clumsy in classic (you'd juggle a string "on"/"off" and compare it, or use an integer value of 0 and 1):

local sneaking = false
cl.bind_key("f3", function()
    sneaking = not sneaking
    cl.message(sneaking and "Sneaking ON" or "Sneaking OFF")
end)

4. Reusable functions (arguments and return values)

Classic: call has no parameters, no return, and shares one scope

A classic "function" macro takes no arguments, returns nothing, and writes into the same flat variable pool as its caller — so a helper that uses a temporary i silently clobbers the caller's i. There are no closures and no recursion safety.

// Classic: a "double it" helper has to communicate through a shared variable
double
{
  set result n * 2      // reads caller's n, writes caller's result — naming discipline only
}
"calc"
{
  set n 21
  call double
  "/think " result "\r"
}

Lua: ordinary functions

local function double(n) return n * 2 end

cl.bind_text("calc", function(rest)
    local n = tonumber(rest) or 0
    cl.send("/think " .. double(n))
end)

Because all Lua files load into one shared world, you can keep a library of helpers in one file and call them from any other — no copy-paste, no global-variable collisions.


5. Putting it together: a party-heal rotation

This is the kind of macro that's a small project in classic (a hand-rolled goto queue of p1_name, p2_name, … with manual index math and string-compare bugs) and a dozen readable lines in Lua — strings, a list, a boolean, and a function all at once:

local party = { "Stinky", "Drablak", "Karina" }
local idx = 1

local function next_member()
    local who = party[idx]
    idx = idx % #party + 1     -- wrap around the list
    return who
end

-- F1 heals the next party member in rotation, skipping yourself
cl.bind_key("f1", function()
    local me = cl.my_simple_name()
    local who = next_member()
    if who and who:lower() ~= (me or ""):lower() then
        cl.send("/useitem caduceus " .. who)
    end
end)

Classic is still the right tool for…

Lua doesn't replace classic — for a huge number of macros, classic is shorter:

  • Pure key bindings: f1 { "/think ready\r" }
  • Text abbreviations / expansions: 'ty' { "thank you!" }
  • One-liner command triggers and quick pause sequences.

Reach for Lua when a macro needs to remember a list, manipulate text, hold a flag, share logic, or branch on more than one condition. That's the dividing line.


Next steps