Ticks and timing
Every non-trivial plugin is a state machine driven by on_tick. This page explains why, and
how to write one that does not fight the host.
The tick loop
on_tick fires every 250ms while the plugin runs. That number comes from the host's own
ticker — the desktop app's trainer and the console both run the same engine at the same rate —
and is passed into the Lua runtime, so it is knowable at runtime rather than assumed:
utils.TickMS() -- 250
utils.Ticks(5) -- 20 (whole ticks in 5 seconds, minimum 1)
utils.Ticks(1.5) -- 6
utils.Ticks(0) -- 0
utils.Ticks floors, and clamps to at least 1 for any positive duration. utils.Ticks(0.1)
is 1 tick, not 0, so a short wait never becomes no wait at all. Only an argument of zero or
less gives you zero.
local ticks = utils.Ticks
local SETTLE_TICKS = ticks(2) -- good
local PULL_INTERVAL_TICKS = ticks(3) -- good
local TICK_MS = 250 -- don't
local RETRY_TICKS = 4 -- don't
The whole point of utils.TickMS() existing is that plugins stop encoding the host's
schedule. Some of the older shipped plugins (kat_farm.lua, garden69.lua) still hardcode
tick counts. They predate the helper and have not been migrated; copy farm_tw.lua or
farm_sm.lua instead.
Ticks are delivered to every running plugin, in name order, one after another on a single goroutine. Two plugins do not tick in parallel, so a plugin that takes half a second in its callback pushes everyone else's tick out by half a second.
Never block inside a tick
Both hosts cancel any Lua call still running after 10 seconds, and a slow on_tick delays
every other plugin. Sleeping inside a tick is the wrong shape:
-- wrong
plugin.on_tick = function()
local c = clients:First()
c:Teleport(x, y)
utils.Sleep(3000) -- 12 ticks nobody else gets
c:ClickWindow(path)
end
Instead, spread the work across ticks and use a counter to wait:
-- right
plugin.on_tick = function()
if state.wait and state.wait > 0 then
state.wait = state.wait - 1
return
end
local c = clients:First()
if not c then return end
if state.step == 'teleport' then
c:Teleport(x, y)
state.wait = utils.Ticks(3)
state.step = 'click'
return
end
if state.step == 'click' then
c:ClickWindow(path)
state.step = 'done'
return
end
end
utils.Sleep still exists and is fine for very short pauses inside a single logical action —
a few hundred milliseconds between two key presses, say. It is not how you wait for the game
to do something.
utils.Sleep is cancellable: when the call's deadline passes, or the plugin is stopped, the
sleep returns early rather than running to completion. So a stuck plugin does unwedge — it
just takes the full ten seconds to do it, with everyone else's ticks stalled behind it.
It covers everything on_tick does, including time spent inside game reads. A tick that does
three teleports with a two-second sleep between each is already over budget on a slow client.
Aim for a tick that does one thing.
The state machine pattern
The shape every shipped plugin converges on:
local state = {}
local function reset()
state = {
enabled = true,
phase = 'hunt',
wait = 0,
-- counters, caches, whatever the phases need
}
end
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 c:IsInCombat() then
-- combat belongs to libstrat; do nothing
return
end
if state.phase == 'settle' then settle(c) return end
if state.phase == 'recover' then recover(c) return end
if state.phase == 'sweep' then sweep(c) return end
hunt(c)
end
plugin.on_load = function() reset() end
plugin.on_tick = tick
Four properties make this work:
One decision per tick. Each branch does one thing and returns. No branch falls through into another. This makes the machine trivially traceable — one log line per tick tells you exactly where it is.
A single wait counter, checked first. Any phase can set state.wait and the next several
ticks become no-ops. This is how you say "let the game settle" without blocking.
state is a table rebuilt by reset(). Reloading a plugin re-executes the file; keeping
mutable state in one table that a function rebuilds means reload and restart behave the same.
It also means a nil field is a bug you can see, rather than a stale value from the last run.
An enabled flag checked before anything else. A hotkey toggle and on_stop both flip
it, and an in-flight tick bails immediately instead of starting a new action on its way out.
Do not build a loop with no floor
A supervisor phase that can complete instantly and immediately re-enter itself will spin as
fast as the tick loop allows. It is the same failure as a while true with no sleep, spread
over ticks, and it is how a combat loop once managed 27,000 engage/exit cycles and about
25,000 log lines a second.
Every phase that can retry needs one of two things: a state.wait it sets before returning,
or a counter that eventually gives up:
local function pull(c)
local ok, err = c:TeleportWithRecovery(target.position)
state.pull_wait = PULL_INTERVAL_TICKS -- the floor: at least this long before retrying
if not ok then
log_throttled('pull', 'teleport failed: ' .. tostring(err))
state.attempts = state.attempts + 1
if state.attempts > 3 then
state.phase = 'sweep' -- the give-up: a different phase
state.attempts = 0
end
return
end
state.attempts = 0
state.phase = 'settle'
end
Set the floor before the branch that can fail, not inside the success path. A retry loop that only rate-limits on success is not rate-limited.
Waiting for a condition
For waits where you have a real predicate rather than a fixed duration, utils.WaitUntil
polls for you and returns a single boolean:
local arrived = utils.WaitUntil(function()
return c:GetZone() == 'WizardCity/WC_Hub'
end, { timeout_ms = 8000, interval_ms = 250 })
Two things to get right:
It blocks. Use it inside execute or a lifecycle hook, not inside on_tick.
Always pass timeout_ms. Its default is 30 seconds, which is three times the 10-second
call budget — a WaitUntil left on its default will be cancelled by the deadline, not by its
own timeout, and the plugin ends up in error status. Keep it comfortably under 10000.
Inside a tick loop, spell the same thing as a phase with a timeout counter:
local WAIT_ZONE_TICKS = utils.Ticks(15)
local function wait_zone(c)
if c:GetZone() == TARGET_ZONE then
state.phase = 'arrived'
return
end
state.waited = state.waited + 1
if state.waited > WAIT_ZONE_TICKS then
log('gave up waiting for ' .. TARGET_ZONE)
state.waited = 0
state.phase = 'hunt'
end
end
That version can wait far longer than ten seconds, because each individual tick is cheap. This is the general answer to "I need to wait a minute for a respawn".
The client also has purpose-built waits that block: WaitForZone, WaitForZoneChange,
WaitForCombat, WaitForCombatEnd, WaitForDialog, WaitForLoading, WaitForWindow. Same
rules — pass a timeout under 10 seconds, and keep them out of on_tick.
Retrying
utils.Retry wraps a fallible operation with attempts and backoff:
local ok, err = utils.Retry(function()
return c:TeleportWithRecovery(target.position)
end, { attempts = 3, backoff_ms = 500, on_error = function(attempt, e)
utils.Log('attempt ' .. attempt .. ' failed: ' .. tostring(e))
end })
The wrapped function must return (boolean, any) — exactly the shape every action binding
already returns, which is why return c:Teleport(...) works directly. Retry returns
true, nil on the first success, or false, <last error> once the attempts are exhausted.
Defaults are 3 attempts and 500ms of backoff.
It blocks for up to attempts * backoff_ms plus the work itself, so the same budget applies:
three attempts at 500ms is fine, ten attempts at 2000ms is over the limit. Inside a tick loop,
a retry is just a phase that does not advance — see the pattern above.
Logging at four hertz
A plugin ticking four times a second will destroy your log output if it logs unconditionally — the console's scrollback or the desktop app's own log view, whichever is watching. Three helpers exist for this:
utils.Log('happens once per real event')
utils.LogOnce('key', 'only the first time this key is used')
utils.LogThrottled('key', 'at most once per interval', 15000)
LogOnce and LogThrottled key off the first argument, and that key is per-plugin and
survives until the plugin is reloaded. Two call sites sharing a key throttle each other, which
is occasionally what you want and more often a bug — prefix the key with the call site.
The convention in shipped plugins is a pair of wrappers so the prefix and throttle interval are declared once:
local LOG_THROTTLE_MS = 15000
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
Use plain log for state transitions — "fight 3 started", "recovered to 88% hp" — because
those are genuinely occasional. Use log_throttled for anything that can happen on
consecutive ticks: failed teleports, wrong zone, no target found.
Getting this wrong is not cosmetic. A combat loop that re-engaged instantly once emitted roughly 25,000 log lines per second and flushed the console's entire scrollback, which made diagnosing the actual bug considerably harder.
plugin.verbose = true and leave itverbose turns on per-read debug logging inside the bindings. A single c:GetZone() fans out
into a dozen memory reads, so a four-hertz tick loop with verbose on produces roughly a
hundred log lines a second. It is read at call time, so you can flip it on for one tick and
back off again — which is how it is meant to be used.
Randomising
utils.Random(min, max) returns an integer in [min, max], inclusive at both ends, defaulting
to Random(0, 100). If max <= min it just returns min.
A little jitter on a repeated wait keeps a bot from looking metronomic:
state.wait = utils.Ticks(3) + utils.Random(0, 2)
math.random also exists — the math library is available, and its two-argument form is
inclusive at both ends as in standard Lua. The catch is that it is not per-plugin: every
plugin's math.random draws from one generator shared by the whole host process, and a
math.randomseed call in one plugin reseeds it for all of them. Prefer utils.Random unless
you specifically want a float.