Skip to main content

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:

Hostc:GetID() returnsOrdering of clients
ConsoleThe alias hook assigned — p1, p2, …Sorted by alias, so stable
Desktop appThe 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() and clients[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 name

It 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
Verify the zone before any positional teleport

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.