utils
Always available. Timing, logging, randomness, and two retry helpers.
Timing
utils.TickMS() --> number the host's tick interval in ms (250)
utils.Ticks(sec) --> number whole ticks in `sec` seconds, minimum 1
utils.Sleep(ms)
Ticks floors, then clamps to at least 1 for any positive input. Ticks(0.1) is 1, not 0 —
a short wait never becomes no wait. Ticks(0) and any negative input are 0, so the clamp is
about rounding, not about refusing zero.
local ticks = utils.Ticks
local SETTLE_TICKS = ticks(2) -- 8
local RETRY_TICKS = ticks(15) -- 60
Hardcoding local TICK_MS = 250 works right up until the host changes its interval, at which
point every duration in your plugin is silently wrong. utils.Ticks reads the real value from
the runtime.
If utils.TickMS() ever returns something other than 250, plugins written against Ticks
follow it and plugins with baked-in numbers do not.
Sleep blocks. It is fine for a couple hundred milliseconds inside a single logical action —
between two key presses, say. It is not how you wait for the game, because a blocked
on_tick delays every other plugin and can hit the VM's 30-second call timeout.
It does return early when the host shuts the plugin down, so a stop is not stuck behind your longest sleep. Zero or negative durations return immediately.
Logging
utils.Log(msg)
utils.LogOnce(key, msg)
utils.LogThrottled(key, msg, throttle_ms)
LogOnce prints the first time a key is seen and never again. LogThrottled prints at most
once per interval per key. Both take the key first and the message second — swapping them
throttles on the message text, which is usually not what you meant.
The key is remembered per plugin VM, so LogOnce fires again after a reload. Its key table is
cleared wholesale if a plugin ever registers more than a few thousand distinct keys, which is
a reason to keep keys to a fixed set of names rather than building them from live values.
At four ticks a second, unthrottled logging destroys your log output fast — the console's scrollback or the desktop app's own log view, whichever is watching. The convention is a pair of wrappers per plugin:
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
Prefixing the throttle key with the plugin name keeps two plugins from sharing a bucket.
Rule of thumb: log for state transitions that happen once per real event ("fight 3
started", "recovered to 88% hp"), log_throttled for anything that can repeat on consecutive
ticks (failed teleports, wrong zone, no target found).
Waiting on a condition
utils.WaitUntil(predicate, opts) --> boolean
local arrived = utils.WaitUntil(function()
return c:GetZone() == 'WizardCity/WC_Hub'
end, { timeout_ms = 15000, interval_ms = 250 })
Options: timeout_ms (default 30000) and interval_ms (default 200). Only positive
values are accepted; anything else falls back to the default.
Returns whether the predicate became true before the timeout. It also returns false if the
predicate itself raised an error, or if the plugin was stopped mid-wait — a false is not
proof that the condition stayed false.
Blocks. Belongs in execute or a lifecycle hook, not in on_tick. At the default 30s
timeout it can consume the VM's entire 30-second call budget on its own.
Retry
utils.Retry(fn, opts) --> ok, err
local ok, err = utils.Retry(function()
return c:TeleportWithRecovery(target.position)
end, {
attempts = 3,
backoff_ms = 500,
on_error = function(attempt, e)
log_throttled('retry', 'attempt ' .. attempt .. ' failed: ' .. tostring(e))
end,
})
fn must return (boolean, any) — which is exactly the shape of every ok, err call in this
API, so most client methods can be passed straight through.
Options: attempts (default 3), backoff_ms (default 500), on_error.
Returns true, nil as soon as an attempt returns truthy. After the last attempt it returns
false plus the last failure value: whatever fn returned second, or the Lua error
message if fn itself raised. on_error(attempt, err) is called after each failed attempt,
including the final one, and its own errors are ignored.
The backoff sleeps between attempts but not after the last one, and it is cut short if the plugin is stopped.
Blocks. In a tick loop, express a retry as a phase that does not advance — see Ticks and timing.
Maths
utils.Random(min, max) --> number
utils.Distance(x1, y1, x2, y2) --> number
Random always returns an integer, inclusive of both bounds. The defaults are min = 0
and max = 100, so a bare utils.Random() gives an integer 0–100 — not a float in
[0, 1). utils.Random() < 0.1 is therefore true only when the draw is exactly 0, roughly 1%
of the time rather than the 10% a float would suggest. For a percentage roll, compare against
an integer:
if utils.Random() < 10 then jitter() end -- ~10%
max <= min returns min rather than erroring. It is backed by a cryptographic source, so it
is fine for jitter you do not want to be predictable:
state.wait = utils.Ticks(3) + utils.Random(0, 2)
Adding a little jitter to a fixed cadence is worth doing on anything long-running. A bot that acts on exactly a 3.000-second period is a more distinctive pattern than one that varies.
Distance is 2D. For positions you already have as tables, p:DistanceTo(other) and
e:DistanceTo(target) are more direct.
Random(min, max) truncates both bounds to integers, so Random(1, 2.9) draws from 1–2.
What is not here
No os.time, no os.date, no os.clock, no io. The sandbox does not open those libraries,
and load, loadstring, dofile, and loadfile are all set to nil — a plugin cannot build
code at runtime. The libraries that are open are base, table, string, and math.
If you need elapsed time, count ticks — utils.Ticks is the intended unit and it survives a
host that changes its cadence. If you need a timestamp in a log line, both hosts already stamp
every entry with one.