Skip to main content

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.

Hotkeys are console-only

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:

  1. It finds a target by the right name — if the first pull fails, the describe_mobs line tells you whether the name is wrong.
  2. Combat engages with the strategy you asked for, not with the host's default. The fighting with X ... not Y line fires once if it did not.
  3. 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.

Next

  • Drops — recording what the farm actually produced
  • Farm loop — the same shape as a cookbook recipe
  • libstrat — writing the strategy that wins the fights