Writing a farm bot
This builds a complete farming plugin from nothing, in the order you would actually write it.
The finished thing is farm_tw.lua, one of the example plugins included with your
installation — a farm bot for one specific mob and zone (Troubled Warriors in Grizzleheim, for
Couch Potato seeds). Read that file alongside this page; the shape generalises to any single
mob in any zone by swapping TARGET_NAME, ZONE, and COMBAT_STRATEGY.
The job: find a specific mob, teleport onto it to start a fight, let libstrat win the fight, recover health and mana, repeat. Forever, unattended, without getting stuck.
Before you start you need a strategy that actually works for the mob you are farming — the
plugin will not fight, it will only hand off. If enable combat <name> does not already win
that fight on its own, write the strategy first: libstrat.
The phases
Everything is a state machine. Sketch the phases before writing code:
Six phases. hunt is the default. fight is where the plugin does nothing and libstrat
works. The rest are transitions that exist because the game needs time.
1. Constants
Every tunable at the top, all durations derived from ticks:
---@type KebabPlugin
plugin = {
name = 'farm_tw',
version = '1.0.0',
description = 'Farms Troubled Warriors for Couch Potatoes',
}
local TARGET_NAME = 'Troubled Warrior'
local COMBAT_STRATEGY = 'storm_cp'
local ZONE = 'Grizzleheim/GH_Hero'
local ticks = utils.Ticks
local SETTLE_TICKS = ticks(2)
local PULL_INTERVAL_TICKS = ticks(3)
local POTION_SETTLE_TICKS = ticks(2)
local NUDGE_MS = 300
local NUDGE_EVERY_PULLS = 3
local KEY_FORWARD = 'w'
local KEY_BACKWARD = 's'
local HEALTH_LOW_PCT = 55
local HEALTH_OK_PCT = 85
local MANA_LOW_PCT = 30
local MANA_OK_PCT = 65
local LOG_THROTTLE_MS = 15000
Two thresholds per resource, not one. HEALTH_LOW_PCT decides when to start recovering,
HEALTH_OK_PCT decides when to stop. Using a single threshold gives you a bot that
oscillates across the boundary, pulling one wisp and going straight back to fighting at 56%.
ZONE as a constant with an empty-string escape hatch means the same plugin can be locked to
one zone or run anywhere.
Getting ZONE right takes one command: hook a client standing where you want to farm and run
dump zone in the console, or read it from a utils.Log(c:GetZone()). It is a
World/Zone string like Grizzleheim/GH_Hero, and the comparison is exact.
2. State and logging
local state = {}
local function log(msg)
utils.Log('[cp] ' .. msg)
end
local function log_throttled(key, msg)
utils.LogThrottled('cp_' .. key, '[cp] ' .. msg, LOG_THROTTLE_MS)
end
local function reset()
state = {
enabled = true,
phase = 'hunt',
waited = 0,
pull_wait = 0,
pulls = 0,
fights = 0,
wisp_pulls = 0,
potions_drunk = 0,
collected = {},
chunks = nil,
chunk_index = 0,
chunk_wait = 0,
}
end
Counters for everything you will want in a log line at 3am: pulls attempted, fights had, wisps grabbed, potions drunk.
The cp_ prefix on the throttle keys is not decoration. Throttle keys are shared across the
whole plugin, so two unrelated call sites using 'tp' would silence each other.
3. The readiness gate
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
Covered in Working with clients. Nothing else in the plugin has to handle a missing or loading client.
clients:First() assumes a single hooked client. Under the console that is the alphabetically
first alias, so p1; under the desktop app with two clients hooked it is arbitrary, so this
plugin is a single-client design. Making it multi-client means picking a client deliberately
rather than taking the first one.
4. Resource maths
local function pct(current, max)
if not max or max <= 0 then return 100 end
return (current / max) * 100
end
local function health_pct(c) return pct(c:GetHealth(), c:GetMaxHealth()) end
local function mana_pct(c) return pct(c:GetMana(), c:GetMaxMana()) end
pct returning 100 when max is zero is the safe default — these reads have no error slot and
return 0 on failure, so a failed read should not look like an emergency and trigger a
recovery loop. It also stops a division by zero producing inf, which then blows up the
string.format('%d', ...) in your log line.
Mana needs more than a percentage, because what matters is whether you can afford to cast:
local POTION_MANA_RESERVE = 0.23
local POTION_MANA_FLOOR = 16
local function mana_floor(c)
local level = c:GetLevel()
if level > 0 and level < POTION_MANA_FLOOR then return level end
return POTION_MANA_FLOOR
end
local function potion_needed(c)
local reserve = (POTION_MANA_RESERVE * c:GetMaxMana()) + mana_floor(c)
if c:GetMana() < reserve then return true end
return health_pct(c) < HEALTH_LOW_PCT
end
local function needs_recovery(c)
if potion_needed(c) then return true end
return health_pct(c) < HEALTH_LOW_PCT or mana_pct(c) < MANA_LOW_PCT
end
0.23 * max_mana + min(16, level) approximates the mana a full round of casting costs. A
low-level wizard has a smaller floor because their spells cost less.
5. Recovery: potions first
local function drink_potion(c)
if not potion_needed(c) then return false end
local charges = c:PotionCount()
if charges < 1 then
log_throttled('no_potions', string.format(
'out of potions at %d%% hp / %d%% mana; falling back to wisps',
math.floor(health_pct(c)), math.floor(mana_pct(c))))
return false
end
local used, err = c:UsePotion()
if not used then
if err then log_throttled('potion', 'potion failed: ' .. tostring(err)) end
return false
end
state.potions_drunk = state.potions_drunk + 1
state.wait = POTION_SETTLE_TICKS
log(string.format('drank a potion (%d left) at %d%% hp / %d%% mana',
math.max(0, charges - 1), math.floor(health_pct(c)), math.floor(mana_pct(c))))
return true
end
A potion restores both health and mana at once, which beats chasing two kinds of wisp. Wisps are the fallback when charges run out.
UsePotion() with no arguments drinks whenever a charge is held, which is why the decision
lives in potion_needed above it. Passing thresholds instead ({ health_percent = 40 }) moves
that decision into the binding, and it returns false with no error when no potion was
needed — so not used is not by itself a failure.
Note state.wait after drinking — the HUD animates and the stat reads lag behind the actual
restore. Without the pause the next tick sees the old values and drinks again.
6. Recovery: wisps as fallback
local WISP_MAX_PULLS = 10
local WISP_MOB_CLEARANCE = 700
local function wanted_kinds(c)
local kinds = {}
if health_pct(c) < HEALTH_OK_PCT then kinds[#kinds + 1] = 'health' end
if mana_pct(c) < MANA_OK_PCT then kinds[#kinds + 1] = 'mana' end
return kinds
end
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
local function recover(c)
if drink_potion(c) then return end
local kinds = wanted_kinds(c)
if #kinds == 0 then
log(string.format('recovered to %d%% hp / %d%% mana after %d wisp(s)',
math.floor(health_pct(c)), math.floor(mana_pct(c)), state.wisp_pulls))
enter_hunt()
return
end
if state.wisp_pulls >= WISP_MAX_PULLS then
log_throttled('wisp_cap', 'wisp limit reached, resuming')
enter_hunt()
return
end
local wisp = pick_wisp(c, kinds)
if not wisp then
log('no wisp in range after ' .. state.wisp_pulls .. ', resuming')
enter_hunt()
return
end
state.collected[wisp.key] = true
state.wisp_pulls = state.wisp_pulls + 1
local ok, err = c:TeleportWithRecovery(wisp.position)
if not ok then
log_throttled('wisp_tp', 'teleport failed: ' .. tostring(err))
end
end
Three separate exits back to hunting: recovered, hit the pull cap, or no wisps left. All three matter. A recovery phase with only the first exit is a bot that sits in an empty zone forever waiting for mana wisps that are not coming.
state.collected[wisp.key] marks the wisp before teleporting, not after. If the teleport
fails you still do not want to immediately retarget the same one. And it uses wisp.key
verbatim — never rebuild that string, or the exclusion set silently stops working and the bot
teleports onto the same wisp forever.
The enter_* helpers are what make the transitions safe to call from anywhere. Each one puts
the whole state block for its phase into a known state:
local function enter_hunt()
state.phase = 'hunt'
state.pull_wait = 0
state.pulls = 0
state.chunks = nil
state.chunk_index = 0
state.chunk_wait = 0
end
local function enter_recover()
state.phase = 'recover'
state.wisp_pulls = 0
state.collected = {} -- wisps respawn; yesterday's exclusions are stale
end
Note that enter_recover resets collected. An exclusion set that only ever grows is a bot
that eventually finds no wisps at all.
7. Hunting
local function pick_target(c)
return c.entity:Nearest({ name = TARGET_NAME, tag = 'mob' })
or c.entity:Nearest({ name = TARGET_NAME })
end
local function hunt(c)
if state.pull_wait > 0 then
state.pull_wait = state.pull_wait - 1
return
end
local target = pick_target(c)
if not target then
if SWEEP_ENABLED and enter_sweep(c) then return end
state.pull_wait = PULL_INTERVAL_TICKS
log_throttled('no_target', 'no ' .. TARGET_NAME .. ' found; ' .. describe_mobs(c))
return
end
state.pulls = state.pulls + 1
if state.pulls > 1 and (state.pulls % NUDGE_EVERY_PULLS) == 1 then
nudge(c)
end
state.pull_wait = PULL_INTERVAL_TICKS
local ok, err = c:TeleportWithRecovery(target.position)
if not ok then
log_throttled('pull', 'teleport to ' .. label_of(target) .. ' failed: ' .. tostring(err))
end
end
state.pull_wait is set before the teleport, not after a successful one. That is the rate
floor: however the pull goes, the next attempt is at least three seconds away. A retry loop
that only rate-limits on success is not rate-limited, and will spin at full tick rate the
moment teleports start failing.
The periodic nudge is empirical. Repeatedly teleporting onto the same coordinates eventually leaves the character in a state where the game stops registering the aggro trigger; a physical forward/back step resets it:
local function nudge(c)
local ok, err = c:SendKey(KEY_FORWARD, NUDGE_MS)
if not ok then
log_throttled('nudge', 'forward key failed: ' .. tostring(err))
return false
end
ok, err = c:SendKey(KEY_BACKWARD, NUDGE_MS)
if not ok then
log_throttled('nudge', 'backward key failed: ' .. tostring(err))
return false
end
return true
end
When there is no target, the log line includes describe_mobs(c) — a sample of what is
visible. That single detail turns "it isn't working" into "ah, the mob is called something
else" without attaching a debugger. See
Debugging what is actually there.
8. Sweeping
When nothing matches in range, the mob may simply be outside the client's ~3,147-unit draw
distance. Client:ZoneChunks() returns positions that between them load the whole zone. The
full pattern is in Finding entities.
Sweeping is optional and worth a flag (SWEEP_ENABLED), because it is not free: each chunk is
a teleport plus a settle, so a large zone can take a minute to cover. In a zone where the mob
is always nearby, turning it off keeps the bot tighter.
9. The tick
local function tick()
if not state.enabled then return end
local c = ready_client()
if not c then return end
if state.wait and state.wait > 0 then
state.wait = state.wait - 1
return
end
if not ensure_combat(c) then return end
if c:IsInCombat() then
if state.phase ~= 'fight' then
state.fights = state.fights + 1
state.phase = 'fight'
log('fight ' .. state.fights .. ' started')
end
return
end
if state.phase == 'fight' then
state.phase = 'settle'
state.waited = 0
return
end
if state.phase == 'settle' then settle() return end
if state.phase == 'nudge' then unstick(c) return end
if state.phase == 'recover' then recover(c) return end
if state.phase == 'sweep' then sweep(c) return end
hunt(c)
end
Read top to bottom, it is a priority list: disabled beats everything, then client readiness, then an explicit wait, then combat setup, then combat itself, then the phase table, and hunting as the default.
Every branch ends in a return. Nothing falls through into the next phase, which is what makes
the machine traceable — one log line per tick tells you exactly where it is.
The phase that closes the loop is unstick, which is the only place that decides between
recovering and hunting:
local function settle()
state.waited = state.waited + 1
if state.waited < SETTLE_TICKS then return end
state.phase = 'nudge'
end
local function unstick(c)
nudge(c)
if needs_recovery(c) then
log(string.format('recovering at %d%% hp / %d%% mana',
math.floor(health_pct(c)), math.floor(mana_pct(c))))
enter_recover()
else
enter_hunt()
end
end
10. Lifecycle
local function belongs_to_us(data)
if data == nil or data.client_id == nil then return true end
local c = clients:First()
if not c then return false end
return tostring(data.client_id) == tostring(c:GetID())
end
local function release()
state.enabled = false
if automation ~= nil then
automation:DisableCombat({ owner = plugin.name })
end
log('stopped after ' .. tostring(state.fights or 0) .. ' fight(s)')
end
plugin.on_load = function()
reset()
log('farming ' .. TARGET_NAME .. ' with ' .. COMBAT_STRATEGY)
end
plugin.execute = function()
reset()
log('restarted')
end
plugin.on_tick = tick
plugin.on_stop = release
plugin.on_unload = release
plugin.on_combat_enter = function(data)
if not state.enabled or not belongs_to_us(data) then return end
if state.phase ~= 'fight' then
state.fights = state.fights + 1
state.phase = 'fight'
end
end
plugin.on_combat_exit = function(data)
if not state.enabled or not belongs_to_us(data) then return end
if state.phase == 'fight' then
state.phase = 'settle'
state.waited = 0
end
end
plugin.hotkeys = {
f8 = function()
state.enabled = not state.enabled
if state.enabled then enter_hunt() end
log('enabled=' .. tostring(state.enabled))
end,
}
release is bound to both on_stop and on_unload. Only on_stop fires when you stop
the plugin; reloading it, and shutting the host down, fire on_unload instead. A plugin that
only cleans up in on_stop leaves combat automation running every time you reload it.
The F8 toggle is worth the four lines under the console. When the bot does something you did not expect, you want to freeze it and look around without unhooking.
The desktop app does not bind a global key listener for plugin hotkeys, so plugin.hotkeys is
inert there. Under the desktop app, use the Stop control on the plugin's row.
Running it
In the desktop app: save the file into ~/.kebab/plugins, make sure the trainer has been
started (the Start control on Forge → Combat), open Forge → Trainer → Plugins, then
Run. A brand-new file needs the trainer stopped and started again before it appears at all —
the folder is only scanned when the trainer starts.
From the console:
scripts reload -- rescans the folder; needed for a new file
scripts run farm_tw
Watch the first two cycles. You are checking three things:
- It finds a target by the right name — if the first pull fails, the
describe_mobsline tells you whether the name is wrong. - Combat engages with the strategy you asked for, not with the host's default. The
fighting with X ... not Yline fires once if it did not. - It recovers rather than fighting at 20% health.
To watch what it is doing without editing the file, scripts shows each plugin's status and
last error, and dump info shows the client's live health, mana, zone and combat state.
What still bites
Zone drift. The ZONE check stops the bot outside its intended zone, but does not bring
it back. Getting home after an accidental transition is not implemented — and dying is exactly
such a transition, so a run that loses a fight parks the bot until you notice.
Potion supply. When charges run out the bot falls back to wisps and keeps farming at lower efficiency. It does not go restock.
PotionCount cannot distinguish empty from unreadable. It returns 0, 0 for both a
transient memory read failure and genuinely having no potions, so a read hiccup looks like an
empty inventory. In practice this is harmless because the fallback is wisps, but do not build
a "go buy potions" behaviour on that signal without corroborating it.
Single client. clients:First() and a shared state table mean one wizard. Two clients
need two plugin files, or a state table keyed by client id and every helper taking the
client explicitly.