Working with clients
clients is the entry point for everything. It is the list of hooked game clients, and it
doubles as a lookup and selection helper.
Getting a client
local c = clients:First() -- the first hooked client, or nil
local c = clients:Get('p2') -- by id, or nil
local c = clients[1] -- by 1-based index
local n = #clients -- how many are hooked
First() is what single-client plugins use. It returns nil when nothing is hooked, which
happens constantly during startup and after a client crashes, so check it every time:
local c = clients:First()
if not c or not c:IsConnected() then return end
Do not cache the result across ticks. The handle is only a string id in a table, so a stale one will not crash you — it just stops resolving, and every call on it starts returning errors that read like the game broke rather than like your handle went stale.
Client ids differ between hosts
This is the single most portable-code-breaking difference between the two hosts:
| Host | c:GetID() returns | Ordering of clients |
|---|---|---|
| Console | The alias hook assigned — p1, p2, … | Sorted by alias, so stable |
| Desktop app | The process id as a string — "48213" | Unspecified and unstable between calls |
Two consequences:
clients:Get('p2')only works under the console. It does an exact string match on the id, so under the desktop app you would have to pass a PID you cannot know in advance.clients:First()andclients[1]are only meaningful in the desktop app when exactly one client is hooked. With two or more, the desktop app's client list is built from an unordered map, so "first" can be a different wizard on consecutive ticks.
Portable code therefore does one of two things: assume a single client and use clients:First(),
or iterate and pick by something you can observe:
local function client_in_zone(zone)
for _, c in ipairs(clients:Connected()) do
if c:GetZone() == zone then return c end
end
return nil
end
Whatever you pick, take ids from c:GetID() or from an event payload's client_id and never
write one as a literal.
Iterating
clients:ForEach(function(c, index)
utils.Log(string.format('%d: %s', index, tostring(c:GetID())))
end)
for _, c in ipairs(clients:Connected()) do
c:UsePotion({ health_percent = 40 })
end
ForEach, Connected() and Select() all skip clients that are not currently connected.
First(), Get(), clients[i] and #clients see the raw list, disconnected entries
included — which is why First() needs its own IsConnected() check and Connected() does
not.
Selecting a subset
clients:Select(spec) takes three forms:
clients:Select('mass') -- every connected client
clients:Select({ 1, 3 }) -- connected clients at index 1 and 3
clients:Select({ except = { 1 } }) -- every connected client but index 1
All three index into the connected list, not the raw one, so the numbers line up with
clients:Connected() rather than with clients[i]. Out-of-range indices are silently
dropped. Anything that is not the string 'mass' or a table returns an empty list.
The except form is the useful one for leader/follower setups — do something to the whole
team except the leader:
local connected = clients:Connected()
local leader = connected[1]
if leader then
local pos = leader:GetPosition()
for _, follower in ipairs(clients:Select({ except = { 1 } })) do
follower:TeleportWithRecovery(pos)
end
end
Because the indices are positional, this is a console idiom in practice — see the ordering caveat above before relying on it in the desktop app.
Readiness
A client being in the list does not mean it is ready to take commands. The shipped plugins all define a gate that runs before anything else:
local ZONE = 'Grizzleheim/GH_Hero'
local function ready_client()
local c = clients:First()
if not c or not c:IsConnected() then return nil end
if c:IsLoading() then return nil end
if ZONE ~= '' then
local zone = c:GetZone()
if zone ~= ZONE then
log_throttled('zone', 'wrong zone ' .. tostring(zone) .. ', expected ' .. ZONE)
return nil
end
end
return c
end
Three checks, in order of cost. IsConnected is cheap. IsLoading catches the zone
transition window where memory reads return garbage. The zone check makes the plugin a no-op
when the operator wanders off somewhere else, rather than teleporting around a zone it does
not understand.
log_throttled rather than log matters here — this runs four times a second, and an
unthrottled message would bury everything else in the log within seconds.
Note that GetZone() returns an empty string while a client is mid-transition or not fully
loaded, so the zone comparison naturally fails closed. Never write the inverse test
(if zone ~= '' then act end) and assume the empty case is rare.
Identity
c:GetID() -- the id string; use for automation and event matching
c:GetPID() -- OS process id
c:GetName() -- the game window's title
GetID() is what you pass to automation:EnsureCombat({ client_id = ... }) and what you
compare against data.client_id in event payloads.
GetName() is not the character nameIt returns the OS window title of the game client, which is identical for every window unless
you have deliberately retitled them. There is no Lua binding that returns the wizard's in-game
name; the memory path that would read it is slow and unreliable enough that it is not
exposed. Log c:GetID() instead.
Reading state
Everything on a client falls into three shapes.
Predicates return a single boolean and swallow their errors, returning false on a failed
read:
c:IsConnected() c:IsInCombat() c:IsLoading() c:IsInDialog()
c:HealthBelow(50) c:HealthAbove(80) c:ManaBelow(30) c:ManaAbove(60)
c:InRange(target, 400) -- 2D: X and Y only, Z is ignored
InRange accepts either a Position ({x=, y=}) or anything with a position field, which
means you can pass an Entity straight in.
Scalar reads return a number with no error slot, and return 0 when the read fails:
c:GetHealth() c:GetMaxHealth() c:GetMana() c:GetMaxMana()
c:GetGold() c:GetLevel()
That is the sharpest edge in the API. A zero is indistinguishable from a read failure, so guard every division:
local function pct(current, max)
if not max or max <= 0 then return 100 end
return (current / max) * 100
end
Returning 100 for the unknown case is the safe default in a farm bot: a failed read should
not look like an emergency and send the bot into a recovery loop.
Actions return (ok, err):
local ok, err = c:TeleportWithRecovery(target.position)
if not ok then
log_throttled('tp', 'teleport failed: ' .. tostring(err))
end
Also useful: c:GetStats() returns one table with health, mana, gold, level, zone, position
and the three state booleans, or nil. Where you need several values at once it is one read
instead of six.
Multi-client shape
For two clients where one leads and one follows, the pattern that works is: the leader does everything the game considers meaningful, and the follower catches up by position.
local function follow()
local leader = clients:Get('p1')
local follower = clients:Get('p2')
if not leader or not follower then return end
if follower:IsInCombat() or follower:IsLoading() then return end
local leader_zone = leader:GetZone()
if leader_zone == '' or follower:GetZone() ~= leader_zone then
return -- cross-zone catch-up is its own problem
end
local pos = leader:GetPosition()
if not follower:InRange(pos, 400) then
follower:TeleportWithRecovery(pos)
end
end
Teleporting to a position from a different zone appears to succeed — the write lands, the coordinates change — but the client is now standing at those coordinates in the wrong world. Worse, the same trap catches you inside a zone transition: a memory teleport into an instance reports success without ever crossing, so a follower can loop forever "arriving" at a leader it never reaches.
Check that both clients report the same non-empty GetZone() before the teleport, and check
it again afterwards before believing the teleport worked.
Cross-zone following needs the leader to go through a door or sigil and the follower to follow through the same transition, not a coordinate write. That is a bigger topic than this page.