Finding entities
"My bot can't find the mob" is the most common failure in this codebase, and it is almost always a naming problem. This page explains the three names every entity has, which one to match against, and what to do when the thing you want is not loaded at all.
Three names, one entity
Every entity carries three separate name fields plus a lang key:
| Field | Example | What it is |
|---|---|---|
display_name | Troubled Warrior | What the player reads. Lang-resolved. |
template_name | GH-Bear-Scout-1-R3 | The ObjectTemplate object name. Stable across languages. |
name | varies | Raw debug name. |
display_key | WizardMobs_00000553 | The lang code, not readable text. |
display_name is empty when the lang files are not loaded. template_name is always there
but bears no resemblance to what you see on screen. Neither one alone is reliable.
This is why entity:Nearest matches against all three:
local target = c.entity:Nearest({ name = 'Troubled Warrior' })
That finds the entity whose internal name is GH-Bear-Scout-1-R3, because display_name
matched. If lang files were not loaded and display_name were empty, the same call would
still work for a plugin that passed the template name instead.
The shipped plugins encode this in a display helper, and it is worth copying — a log line that prints both names turns a naming bug into a five-second diagnosis:
local function present(value)
local s = tostring(value)
if s == '' or s == 'nil' then return nil end
return s
end
local function identity_of(e)
return present(e.display_name) or present(e.template_name) or present(e.name) or '<unnamed>'
end
Matching is case- and separator-insensitive. Both the needle and each candidate are squashed
down to lowercase alphanumerics before a substring test, so "Troubled Warrior",
"troubled warrior", and "troubledwarrior" are the same query. Punctuation, spaces, and
hyphens all disappear — which is what makes GH-Bear-Scout-1-R3 matchable by a needle that
contains none of those characters.
It is a substring match, not an exact one, and that cuts both ways. name = 'Warrior'
finds Troubled Warriors and also every other Warrior in the zone; the nearest one wins. When a
zone has several similarly-named mobs, pass the longest name that is still stable.
If the squashed needle is empty but the input was not — name = "???" — the query is marked
unmatchable and returns nil rather than matching everything. This is deliberate: silently
matching every entity because your name string was garbage would be much worse.
An empty needle is different. name = '' and omitting name both mean "no name filter",
so they match everything.
Nearest
entity:Nearest is the workhorse. It takes a string or a table:
c.entity:Nearest('Troubled Warrior')
c.entity:Nearest({
name = 'Troubled Warrior',
tag = 'mob',
min_mob_distance = 700,
exclude = state.collected,
})
| Option | Effect |
|---|---|
name | Matched against display_name, template_name, and name |
tag | Entity must carry this tag |
min_mob_distance | Prefer a match at least this far from the nearest mob |
exclude | A set keyed by Entity.key |
Filters are ANDed. All of them are optional; Nearest() with no arguments is just the
closest entity.
It returns one value — an Entity or nil. There is no error slot, so a failed read of
the entity list is indistinguishable from an empty zone; the failure is logged by the host but
your plugin just sees nil. Treat "nothing found" as a normal, frequent outcome and always
have a branch for it.
"Nearest" is measured in 2D. Entity.distance is the X/Y distance from the player's current
position with Z ignored, which is what you want in a game with vertical stacking — a mob on
the floor above you counts as close, because a teleport to its coordinates lands you next
to it.
Falling back through specificity
Tags narrow hard, and an entity that is not tagged the way you expect vanishes from the result. The shipped pattern is to try the specific query and then relax it:
local function pick_target(c)
return c.entity:Nearest({ name = TARGET_NAME, tag = 'mob' })
or c.entity:Nearest({ name = TARGET_NAME })
end
If the mob tag is correct you get a real mob; if the tagging is off for that particular template you still find the thing by name.
min_mob_distance
This exists for wisp collection. Teleporting onto a health wisp that happens to be sitting inside a mob pack starts a fight you did not want, so you ask for a wisp with clearance:
local wisp = c.entity:Nearest({
tag = 'wisp_health',
min_mob_distance = 700,
exclude = state.collected,
})
The semantics are prefer, not require. The finder tracks two candidates — the plain nearest match and the nearest match with sufficient clearance — and returns the clear one if it exists, otherwise the plain nearest. So you always get something if anything matched, and you should still check whether the result is somewhere sane before committing.
An entity with no other mobs anywhere in the zone counts as clear, since there is nothing to
be too close to. Clearance is measured against everything tagged mob, including the mob you
are about to fight, so do not use min_mob_distance when hunting.
exclude and Entity.key
exclude is a set keyed by Entity.key. That field is the entity's global_id, or
addr:<address> when the game reports no global id.
Use e.key verbatim. Do not construct "addr:" .. e.address or use e.global_id directly —
the fallback logic lives in Go and a hand-built key will silently fail to match, which means
your exclusion set does nothing and your bot teleports to the same wisp forever.
local wisp = pick_wisp(c, kinds)
if not wisp then return end
state.collected[wisp.key] = true -- correct
c:TeleportWithRecovery(wisp.position)
Both a set ({ [key] = true }) and a plain list ({ key1, key2 }) are accepted. A set entry
mapped to false is ignored, so you can un-exclude by assigning false rather than deleting.
Remember to clear the set when the reason for excluding expires. farm_tw resets
state.collected every time it enters recovery, because wisps respawn and yesterday's
exclusions are stale. An exclusion set that only ever grows is a bot that eventually finds
nothing.
Note that entities keyed by address get a new key when the client reloads them, which a zone transition does. That is usually harmless, but it does mean an exclusion set cannot survive a zone change in any meaningful way.
Other finders
c.entity:FindAll() -- every loaded entity
c.entity:FindByName('Bear') -- all name matches, unordered
c.entity:FindNearest('Bear') -- nearest by name only
c.entity:FindByTag('mob') -- all with a tag
c.entity:GetMobs()
c.entity:GetNPCs()
c.entity:GetWisps('health') -- 'health' | 'mana' | 'gold' | 'all' (default 'all')
All the list-returning finders give you a plain array table, empty on failure, and never an
error. They are not sorted by distance — only Nearest and FindNearest do that
selection for you. If you need "the three closest mobs", sort the list yourself on
e.distance.
Nearest supersedes FindNearest — it is the newer API and takes filters. FindNearest
remains for compatibility.
Tags
The classifier attaches these tags. An entity can carry several: a health wisp is tagged both
wisp and wisp_health.
| Tag | Meaning |
|---|---|
player | A wizard, including yours |
npc | A non-hostile interactable character |
mob | A hostile that starts a duel |
wisp | Any wisp |
wisp_health / wisp_mana / wisp_gold | Wisps by kind |
reagent | A harvestable reagent node |
pet | A pet following someone |
mount | A mount |
interactable | Something clickable that is not an NPC |
Entity.entity_type carries the single primary classification for the same entity, and
Entity.tags is the full array — e:HasTag('mob') is the ergonomic way to test one.
Wisp tags let you build the tag from a kind string:
local function pick_wisp(c, kinds)
for _, kind in ipairs(kinds) do
local wisp = c.entity:Nearest({
tag = 'wisp_' .. kind,
min_mob_distance = WISP_MOB_CLEARANCE,
exclude = state.collected,
})
if wisp then return wisp end
end
return nil
end
Ordering kinds puts the priority in the caller's hands — ask for health first when you are
low on health, mana first when you are low on mana.
Mana wisps restore a small, level-scaled amount. For sustained farming, potions are the real
answer and wisps are the fallback when you are out of charges. See
UsePotion.
When the entity is not there at all
FindAll only returns entities the client has actually loaded, and the client only loads
entities within roughly 3,147 units. A mob on the far side of a large zone does not exist as
far as your plugin is concerned.
Client:ZoneChunks() solves this. It returns a list of positions spaced so that standing on
each one loads every entity assigned to it — sweep them and you have covered the zone:
local SWEEP_SETTLE_TICKS = utils.Ticks(1.5)
local SWEEP_RETRY_TICKS = utils.Ticks(15)
local function enter_sweep(c)
local chunks, err = c:ZoneChunks()
if not chunks or #chunks == 0 then
log_throttled('chunks', 'cannot sweep the zone: ' .. tostring(err or 'no chunks'))
return false
end
state.chunks = chunks
state.chunk_index = 0
state.chunk_wait = 0
state.phase = 'sweep'
log('no ' .. TARGET_NAME .. ' in range, sweeping ' .. #chunks .. ' chunk(s)')
return true
end
local function sweep(c)
if state.chunk_wait > 0 then
state.chunk_wait = state.chunk_wait - 1
return
end
if pick_target(c) then
log('found ' .. TARGET_NAME .. ' after ' .. state.chunk_index .. ' chunk(s)')
state.chunks = nil
enter_hunt()
return
end
state.chunk_index = state.chunk_index + 1
local chunk = state.chunks[state.chunk_index]
if not chunk then
log('swept the whole zone without finding ' .. TARGET_NAME)
state.chunks = nil
enter_hunt()
state.pull_wait = SWEEP_RETRY_TICKS
return
end
local ok, err = c:TeleportWithRecovery(chunk)
if not ok then
log_throttled('sweep_tp', 'chunk teleport failed: ' .. tostring(err))
end
state.chunk_wait = SWEEP_SETTLE_TICKS
end
Chunks come back sorted nearest-first from the player's current position, or from
opts.from if you pass one. Checking for the target before advancing the index means you
stop as soon as it appears rather than completing a pointless full sweep.
The settle wait after each teleport is required. Entities stream in asynchronously; querying immediately after arriving finds nothing and you skip past a chunk that did contain your target.
state.chunk_wait is set unconditionally at the end, not only on a successful teleport. A
sweep step that fails and immediately retries at full tick rate is the classic no-rate-floor
spin.
Options and failure modes
c:ZoneChunks({ entity_distance = 2000, from = { x = 100, y = 200, z = 0 } })
entity_distance overrides the assumed draw distance; the chunk spacing is 90% of it, so a
smaller number gives you more, tighter chunks and a slower but more thorough sweep. Values
below 100 are clamped up, and a zone can yield at most 4,096 chunks.
ZoneChunks returns nil, err rather than an empty list when it cannot work at all. The
error strings you will actually see:
| Error | Meaning |
|---|---|
load zone navmesh: … | The zone's navigation data could not be read — often a zone with no nav mesh at all. |
zone navmesh has no vertices | Loaded, but empty. Same practical outcome. |
zone chunks unavailable for this client | The client does not support the survey — usually means it is not properly hooked. |
All of them mean the same thing to a plugin: sweeping is not available here. Which is why
enter_sweep returns a boolean the caller falls back on rather than assuming a sweep always
starts.
The chunk positions derive from the zone's navigation mesh, so a large outdoor zone yields a handful and a small interior yields one.
Debugging what is actually there
When a query returns nothing, print what the client can see:
local MOB_NAME_SAMPLE = 8
local function describe_mobs(c)
local names, seen = {}, {}
for _, e in ipairs(c.entity:GetMobs()) do
local n = identity_of(e)
if not seen[n] and #names < MOB_NAME_SAMPLE then
seen[n] = true
names[#names + 1] = n
end
end
if #names == 0 then return 'no mobs visible' end
return 'nearby mobs: ' .. table.concat(names, ', ')
end
Capping the sample matters — a busy zone has dozens of mobs and you only need enough to see whether the name you are matching bears any resemblance to reality. Put this in the "no target found" log line and the failure explains itself:
log_throttled('no_target', 'no ' .. TARGET_NAME .. ' found; ' .. describe_mobs(c))
From the console, dump entities prints the same information as a table:
dump entities -- nearest first, default limit
dump entities 40 -- more rows
dump entities mob -- only entities of that kind, or carrying that tag
dump entities p2 20 wisp -- a specific client, a limit, and a filter
The columns are ADDR KIND DIST TEMPLATE DISPLAY INTERNAL, where DISPLAY is exactly
display_name and INTERNAL is template_name/name. That is the fastest way to find the
string to put in your plugin.