Skip to main content

Client

Obtained from clients:First(), clients:Get(id), or clients[n]. Everything you can do to a game client hangs off this object, either as a direct method or as one of its sub-objects.

Sub-objects

FieldTypeAvailability
c.combatCombatControllerAlways
c.questQuestControllerAlways
c.dialogDialogControllerAlways
c.entityEntityFinderAlways
c.inventoryInventoryControllerAfter require('inventory')
c.petPetControllerAfter require('pet')
c.fishingFishingControllerAfter require('fishing')
c.spellSpellControllerAfter require('spell')
c.equipmentEquipmentControllerAfter require('equipment')
c.gardenGardenControllerAfter require('garden')
c.dropsDropsControllerAfter require('drops')

See opt-in modules.

Touching an opt-in sub-object before require is an error, not a nil

c.pet without a prior require('pet') raises a Lua error — pet module not required - call require("pet") first — which aborts the current hook. It does not evaluate to nil, so if c.pet then is not a usable capability check. Call require once at file scope.

Identity

c:GetName() --> string the game window's title, '' if unknown
c:GetID() --> string stable client id
c:GetPID() --> number OS process id, 0 if unknown

GetName() returns the window title, not the character name. On a logged-in client that title usually contains the character name, but it is whatever the game put in the title bar, and it is '' when the title could not be read.

GetID() is what you pass to automation and compare against data.client_id in event payloads. It is the only identifier that is stable and yours to key state on.

State predicates

c:IsConnected() --> boolean
c:IsInCombat() --> boolean
c:IsLoading() --> boolean
c:IsInDialog() --> boolean

All four return false when the underlying read fails — there is no error return, so a transient memory hiccup looks like "not in combat" rather than "do not know". For anything that must not act on a false negative, confirm on the next tick before changing state.

IsInCombat() requires both a live duel pointer and a duel phase that has not ended. See the note on combat state.

Stats

c:GetStats() --> PlayerStats|nil
c:GetHealth() --> number
c:GetMaxHealth() --> number
c:GetMana() --> number
c:GetMaxMana() --> number
c:GetGold() --> number
c:GetLevel() --> number

GetStats() returns everything in one read, which is cheaper than six calls when you need several values:

local s = c:GetStats()
if s then
utils.Log(string.format('%d/%d hp, %d/%d mana, level %d in %s',
s.current_health, s.max_health, s.current_mana, s.max_mana, s.level, s.zone_name))
end

PlayerStats fields: current_health, max_health, current_mana, max_mana, gold, level, zone_name, is_in_combat, is_loading, is_in_dialog, position.

GetStats() returns nil on a failed read. The six single-value getters return 0 on a failed read instead, which is why the threshold helpers below exist — GetHealth() == 0 is not proof you are dead.

Threshold helpers

c:HealthBelow(pct) --> boolean
c:HealthAbove(pct) --> boolean
c:ManaBelow(pct) --> boolean
c:ManaAbove(pct) --> boolean

Percentages are 0–100. These avoid the divide-by-zero you get from computing the ratio yourself when a read fails and max comes back as 0.

Location

c:GetZone() --> string e.g. 'Grizzleheim/GH_Hero'
c:GetPosition() --> Position { x, y, z }
c:InRange(target, dist) --> boolean

GetZone() returns '' when the zone could not be read. Treat empty as "unknown", never as "somewhere else" — a zone gate written as if c:GetZone() ~= FARM_ZONE then leave() end walks out of the zone on a single failed read.

GetPosition() always returns a table, never nil. On a failed read that table is { x = 0, y = 0, z = 0 }, so an origin-looking position is the failure signal.

InRange is a 2D check — X and Y only, Z ignored. That is usually what you want, since a target on a bridge above you is still "here" for interaction purposes. It accepts either a Position or an Entity.

Movement

c:Teleport(x, y, z) --> ok, err
c:Teleport(position) --> ok, err
c:TeleportWithRecovery(x, y, z) --> ok, err
c:QuestTeleport() --> result, err
c:Goto(x, y, opts) --> ok, err
c:GotoStep(x, y, opts) --> done, err
c:SetYaw(angle) --> ok, err

Both teleports take either three numbers or a single Position-shaped table. In the numeric form z is optional and defaults to 0; in the table form a missing x, y, or z also reads as 0, so passing a table that is not a position silently teleports you to the origin.

Teleport vs TeleportWithRecovery

Teleport writes the position and returns. TeleportWithRecovery writes it, then verifies the character actually ended up there, and steps back toward the origin if the game rubber-banded it into geometry.

Use TeleportWithRecovery by default. Plain Teleport is for when you have already verified the destination is walkable and want the lower latency.

A positional teleport never crosses zones

Writing coordinates from zone A that belong to zone B appears to succeed — the write lands, GetPosition() reports the new coordinates — but the character is now standing at those coordinates in the wrong world.

Always verify GetZone() matches before trusting a positional teleport for cross-zone work. Zone changes need a door, a sigil, or QuestTeleport.

QuestTeleport

Teleports toward the active quest objective and reports what happened:

local result, err = c:QuestTeleport()
if result then
utils.Log('outcome: ' .. result.outcome .. ' (' .. result.reason .. ')')
end

result.outcome is one of:

OutcomeMeaning
successArrived at the objective
zone_changeThe teleport moved you to a different zone
combat_enteredA fight started en route — this counts as progress, not failure
rubber_bandedThe game rejected the position and moved you back
zone_requiredThe objective is in another zone; a door is needed
indeterminateCannot tell whether it worked
unknown

zone_required is not a failure, it is information: the goal is elsewhere and you need a transition. Treating it as an error produces bots that retry forever.

Also on the result: reason, from_zone, to_zone, position.

QuestTeleport is the one movement call that returns nil, err rather than false, err on failure — check result, not ok. A returned result always carries an outcome; the error slot is nil whenever a result came back.

Goto and GotoStep

Goto walks to a point, blocking until it arrives or times out. GotoStep performs one step of the same walk and returns whether it is done — use it inside a tick loop:

local done, err = c:GotoStep(x, y, { duration_ms = 250, tolerance = 60 })
if done then state.phase = 'arrived' end

Goto options: timeout_ms, tolerance, max_step_ms. Each is applied only when positive; anything else falls back to the navigator's own default.

GotoStep options: tolerance (default 25 units) and duration_ms (default 100), which caps how long the single step holds the walk key. Its two returns read differently from every other call on this page:

doneerrMeaning
truenilAlready within tolerance — arrived
falsenilWalked one step, not there yet — call it again next tick
falsestringThe step failed

So false alone is not a failure. Branch on err first, then on done.

SetYaw

c:SetYaw(angle) --> ok, err

Turns the character to face a heading, in radians, without moving it. Goto and GotoStep already turn before they walk, so this is for aiming at something you are not walking to — lining up a fishing cast, or facing an NPC before clicking. It returns false, 'navigator unavailable' on a client whose navigator has not come up yet.

Zone survey

ZoneChunks

c:ZoneChunks(opts) --> Position[]|nil, string|nil

Centre points of the current zone, spaced so that standing on each loads every entity assigned to it. Sweep them to find entities outside the draw distance.

local chunks, err = c:ZoneChunks()
local chunks, err = c:ZoneChunks({ entity_distance = 2000, from = c:GetPosition() })

entity_distance defaults to 3,147 units — the client's entity draw distance — and chunks are spaced at 90% of it for overlap. from sets the sort origin; without it, chunks come back nearest-first from the player.

Returns an error for zones with no navigation data, so always check.

PotionCount

c:PotionCount() --> current, max

Potion charges held and the maximum. A potion restores both health and mana.

Failure is indistinguishable from empty

On a read failure this returns 0, 0 and logs — there is no error return. A transient memory read hiccup therefore looks exactly like having no potions.

The signature is (number, number), not (number, number, err). Do not build behaviour that must distinguish the two cases on this signal alone; corroborate with a second read or accept the false negative.

UsePotion

c:UsePotion() --> used, err
c:UsePotion({ health_percent = 40, mana_percent = 30 }) --> used, err

Clicks the potion HUD button. With no thresholds it drinks whenever a charge is held; with thresholds it drinks only when health or mana is below them.

Returns false with no error when no potion was needed or none were held. So used == false, err == nil is a normal outcome, not a problem:

local used, err = c:UsePotion()
if err then log_throttled('potion', tostring(err)) end
if used then state.potions_drunk = state.potions_drunk + 1 end

Pause a tick or two after drinking — the stat reads lag the HUD animation.

Input

c:SendKey(key, duration_ms) --> ok, err
c:SetCursor(x, y) --> ok, err

duration_ms is the hold time and defaults to 100. key is either a raw virtual-key number or one of these names (case-insensitive):

GroupNames
Letters, digitsaz, 09
Editingenter/return, escape/esc, space, tab
Arrowsup, down, left, right
Modifiersshift, ctrl/control, alt
Navigationpageup/page_up/prior, pagedown/page_down/next, home, end
An unknown key name is not rejected

A name outside that table maps to key code 0 and is sent anyway — the binding never validates it. SendKey('f5') presses nothing at all (there are no function keys here), and SendKey('shft') is a silent no-op. Check the spelling against the list; the return value will not tell you.

Pass a raw virtual-key number for anything the table does not name.

pagedown marks a location and pageup recalls to it. Getting them the wrong way round makes a farm loop re-mark wherever it happens to be standing instead of returning to its spot.

SetCursor(x, y) moves the cursor to a client-relative integer pixel position without clicking.

ExecuteCommand does not work

c:ExecuteCommand(cmd) --> ok, err
This always fails, by design

Every host routes it into libwiz, which refuses: libwiz: ExecuteCommand failed: remote code execution is disabled.

Executing game code is a confirmed ban vector, so the binding is kept only so the seam exists. Nothing you pass will run. Drive the game through SendKey, ClickWindow, and the data writes the rest of this API exposes.

Windows

c:ClickWindow(path) --> ok, err
c:IsWindowVisible(path) --> visible, err
c:IsControlGrayed(path) --> grayed, err
c:GetWindowText(path) --> text, err
c:WaitForWindow(path, timeout_ms) --> ok, err

path is an array of window names, or a name from the paths global. See Windows and UI.

A path may also be given as a single string, which is split on / and >.

An empty path segment is real, and only the array form can express it

Several genuine game paths contain an unnamed window — paths.PetGameRewards is { 'WorldView', 'PetGameSplash', '', 'PetGameRewards' }. That '' matches a child whose name really is empty; it is a step in the tree, not padding.

The string form drops empty segments while splitting, so 'WorldView/PetGameSplash//PetGameRewards' resolves to a three-step path that matches nothing. Never join a paths.* value into a string, and write any path containing a blank as an array.

IsControlGrayed returns true when the window is missing (alongside an error), so a readiness poll cannot mistake an absent window for a ready one. It reads the ControlGrayed field the game uses to mark a control busy, not the window's disabled flag.

GetWindowText only returns sensible data for ControlText-derived widgets; other window types return unusable bytes. It also ignores visibility — a popup that has been closed still reports the text it last held, so a plugin that polls text alone will act on a stale message. Gate it:

local visible = c:IsWindowVisible(paths.PopupMessage)
local text = visible and c:GetWindowText(paths.PopupMessage) or ''

WaitForWindow defaults to a 10-second timeout and blocks — keep it out of on_tick. A non-positive timeout returns false, 'timeout must be positive' instead of waiting forever.

Blocking waits

c:WaitForCombat(timeout_ms) --> boolean
c:WaitForCombatEnd(timeout_ms) --> boolean
c:WaitForZone(zone, timeout_ms) --> boolean
c:WaitForZoneChange(timeout_ms) --> boolean
c:WaitForDialog(timeout_ms) --> boolean
c:WaitForLoading(timeout_ms) --> boolean

Every one of them takes timeout_ms as its last argument and defaults it to 60000 — a full minute, which is a very long time to hold up a tick loop. Pass an explicit timeout.

They return a bare boolean: true if the condition was met, false if it timed out, if the client could not be resolved, or if the underlying reads were failing the whole time. There is no error channel, so a timeout and a broken client look identical.

WaitForZone() with no zone name behaves exactly like WaitForZoneChange() — it waits for the zone to become something other than what it is now. Pass a zone name to wait for a specific destination.

All of these block the calling coroutine. They belong in execute or a lifecycle hook, never in on_tick. The tick-loop equivalent is a phase with a counter — see Ticks and timing.

Quest shortcut

c:ActiveQuest() --> Quest|nil

Same as c.quest:GetActiveQuest(), and nil on any failure. See Quest and Dialog.