Skip to main content

events

Two ways to subscribe: declare an on_* field on the plugin table, or register imperatively through the events global. Both feed the same dispatcher.

events.On(event, callback) --> nothing
events.OnHotkey(key, callback) --> nothing

Neither returns anything — there is no handle and no way to unsubscribe. Both are literally assignments: events.On('zone_change', fn) sets plugin.on_zone_change = fn, and events.OnHotkey('f8', fn) sets plugin.on_hotkey_f8 = fn (the key is lowercased).

One handler per event — the last registration wins

Because they are assignments, a second events.On('zone_change', ...) replaces the first, and either one replaces a plugin.on_zone_change you declared at the top of the file. Nothing warns you; the earlier callback simply stops running.

If two parts of your plugin care about the same event, register one handler that calls both. This is also why drops:Listen and a hand-written on_drop cannot coexist.

The event name is not validated

events.On accepts any string and creates plugin.on_<whatever>. A typo — zone_changed, combat_start — is not an error, it is a handler that never fires. Copy names from the table below.

Prefer the plugin table form for anything static — the loader discovers those automatically and they show up in scripts. Use events.On when the subscription is conditional:

plugin.on_load = function()
reset()
if TRACK_ZONES then
events.On('zone_change', function(data)
log('moved from ' .. tostring(data.old_zone) .. ' to ' .. tostring(data.new_zone))
end)
end
end

Available events

Every payload carries client_id.

Eventplugin fieldExtra payload
combat_enteron_combat_enter
combat_exiton_combat_exit
zone_changeon_zone_changeold_zone, new_zone
dialog_openon_dialog_opendialog_type, mobile_id, quest_id, total_pages
dialog_closeon_dialog_close
loading_starton_loading_start
loading_endon_loading_end
dialog_page_changedon_dialog_page_changedcurrent_page, total_pages
diedon_died
level_upon_level_uplevel, old_level
health_changedon_health_changedcurrent_health, max_health, old_health
mana_changedon_mana_changedcurrent_mana, max_mana, old_mana
npc_range_enteredon_npc_range_entered
npc_range_exitedon_npc_range_exited
dropon_dropname, kind, quantity, time, seq, and when present raw_kind, zone, character, run_id, source — see Drops
client_connectedon_client_connectedpid, window_title, connected, hooks_active
client_disconnectedon_client_disconnectedpid

Payload keys are exactly as listed. zone_change carries old_zone and new_zone — not zone. dialog_open carries no dialog text; it identifies the dialog, and you read the text from the UI with GetWindowText.

Optional keys are genuinely optional: a payload only carries a field the source could fill in. data.old_zone can be nil on the first zone change after a client attaches, and every drop field except the first five is conditional. Use tostring(data.x) in log lines and data.x or default in logic.

When a plain assignment is noticed

The loader scans the plugin table for on_* functions twice: when the file loads, and again immediately after on_load returns. So declaring plugin.on_zone_change at file scope or inside on_load both work.

An assignment made any later than that — from a tick, from another event handler, from execute — is not picked up, and the handler never fires. events.On registers the subscription as well as the function, so it works at any point; that is why the conditional form above uses it rather than a plain assignment.

Stat events carry the previous value alongside the current one, so you can see the delta without tracking it yourself:

plugin.on_health_changed = function(data)
local lost = data.old_health - data.current_health
if lost > 0 then
utils.Log(string.format('took %d damage (%d/%d)',
lost, data.current_health, data.max_health))
end
end

plugin.on_level_up = function(data)
utils.Log('level ' .. data.old_level .. ' -> ' .. data.level)
end
Stat events are chatty

health_changed and mana_changed fire on every observed change, at the state watcher's poll rate. During a fight that is a steady stream. Do the cheap check first and keep the handler short — and never log unthrottled from one. If you only care about crossing a threshold, compare against a stored flag rather than reacting to every tick:

plugin.on_health_changed = function(data)
local pct = (data.current_health / data.max_health) * 100
local low = pct < 35
if low ~= state.was_low then
state.was_low = low
if low then utils.Log('health critical') end
end
end

on_tick, on_load, on_stop, on_unload, execute, and on_combat_round are lifecycle callbacks rather than events — they are not subscribable through events.On. See Plugin lifecycle. on_tick, on_load, on_stop, and on_unload are called with no arguments; only the event handlers receive a payload.

There is also a hooks list on the plugin table for subscribing to an event you have no function for yet:

plugin.hooks = { 'zone_change', 'combat' }

Names are lowercased and prefixed with on_ if you leave the prefix off, so 'zone_change' and 'on_zone_change' are the same subscription. 'combat' is the one alias that expands, covering on_combat, on_combat_enter, and on_combat_exit. Declaring the on_* function is enough on its own — the list is only needed when the handler is installed later.

Filtering by client

Events reach every plugin regardless of which client produced them.

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

plugin.on_combat_enter = function(data)
if not state.enabled or not belongs_to_us(data) then return end
state.phase = 'fight'
end

That version treats a payload with no client_id as "ours", which suits a single-client plugin. If you actually drive several clients, invert the default and require an explicit match — otherwise an unattributed event gets processed once per plugin instance.

Compare tostring on both sides. Ids arrive as strings but may be built from numeric sources elsewhere, and "12" ~= 12 in Lua.

Events plus polling

Events can be missed — a hook rebind during a game patch, a client that reattaches. Polling never misses but is up to 250ms late. Non-trivial plugins do both:

-- event: prompt
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

-- poll: reliable
local function tick()
-- ...
if state.phase == 'fight' and not c:IsInCombat() then
state.phase = 'settle'
state.waited = 0
end
end

Both paths guard on state.phase, so whichever fires first wins and the other is a no-op. That guard is what makes having both safe rather than double-counting.

Hotkeys

plugin.hotkeys = {
f8 = function() state.enabled = not state.enabled end,
f9 = function() reset() end,
}

events.OnHotkey('f10', function()
utils.Log('phase=' .. tostring(state.phase))
end)

Same mechanism — both end up as plugin.on_hotkey_<key>. Callbacks take no arguments.

Which keys are bindable, and when

On the console, a plugin hotkey must be az, 09, or f1f12. Modifiers, arrows, and combinations are not names it knows. An unrecognised name, or one the console has already claimed for itself, is skipped silently — no error, the key simply does nothing.

Bindings are also claimed when the plugin file is loaded, so register hotkeys at file scope (either form works there). An events.OnHotkey first reached inside on_load sets up the handler but has no OS binding until the next scripts reload.

The desktop app dispatches hotkeys from its own UI instead, so that key list is a console constraint rather than a rule about the Lua API.

A pause toggle is worth adding to anything long-running. A status dump on another key is worth almost as much — when a bot is stuck you want to know which phase it is in without adding log lines and reloading.

Linux input permissions

Global hotkeys read /dev/input/event*. If they silently do nothing on the console, that is usually a permissions problem rather than a code problem — run it with --sudo or add yourself to the input group. This page has not verified whether the desktop app needs the same on Linux; if its hotkeys are similarly silent, the same input group membership is worth trying first.