Skip to main content

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:

FieldExampleWhat it is
display_nameTroubled WarriorWhat the player reads. Lang-resolved.
template_nameGH-Bear-Scout-1-R3The ObjectTemplate object name. Stable across languages.
namevariesRaw debug name.
display_keyWizardMobs_00000553The 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.

A needle of pure punctuation matches nothing

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,
})
OptionEffect
nameMatched against display_name, template_name, and name
tagEntity must carry this tag
min_mob_distancePrefer a match at least this far from the nearest mob
excludeA 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.

Never rebuild the key yourself

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.

TagMeaning
playerA wizard, including yours
npcA non-hostile interactable character
mobA hostile that starts a duel
wispAny wisp
wisp_health / wisp_mana / wisp_goldWisps by kind
reagentA harvestable reagent node
petA pet following someone
mountA mount
interactableSomething 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.

Wisps are not a mana strategy

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:

ErrorMeaning
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 verticesLoaded, but empty. Same practical outcome.
zone chunks unavailable for this clientThe 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.