Lua Scripting: Hooks & Lifecycle
This page covers every hook a Lua power can define, the order hooks run in, how each boss gets its own isolated runtime, and which standard-library functions are available inside the sandbox.
If you have not written a Lua power yet, start with Getting Started first.
This page documents hooks for boss Lua powers in plugins/EliteMobs/powers/. NPC Lua scripts use their own plugins/EliteMobs/npc_scripts/ folder and NPC-specific hooks like on_npc_interact and on_npc_proximity_enter; see NPC Scripts.
Hook Reference
Every Lua power file returns a table. Each key in that table (besides api_version and priority) must be one of the hooks listed below. The runtime calls the matching function whenever the corresponding game event fires.
| Hook | Fires when | context.player available? |
|---|---|---|
on_spawn | The elite mob spawns | No |
on_game_tick | Once every server tick while the runtime clock is active and unpaused; nominally every 50 ms at 20 TPS | No |
on_boss_damaged | The boss takes damage from any source | No |
on_boss_damaged_by_player | The boss takes damage from a player | Yes |
on_reinforcement_damaged_by_player | A reinforcement summoned by this boss takes player damage; the hook runs on the summoner's powers | Yes |
on_boss_damaged_by_elite | The boss takes damage from another elite mob | No |
on_player_damaged_by_boss | A player takes damage from this boss | Yes |
on_enter_combat | The boss enters combat | Yes |
on_exit_combat | The boss leaves combat | No |
on_heal | The boss heals | No |
on_boss_target_changed | EliteMobs reports a player-target event, including initial acquisition; not every target change or target loss | Yes |
on_death | The boss dies | No |
on_phase_switch | A phase boss switches to a new phase | No |
on_mind_action | A native Mind requests a semantic action from its attached powers; see Mind action context | No |
on_zone_enter | An entity enters a watched zone | Yes (if the entity is a player) |
on_zone_leave | An entity leaves a watched zone | Yes (if the entity is a player) |
When context.player is listed as "No", accessing it returns nil. Always nil-check before using it.
on_boss_damaged and the more specific damage hooks are separate event paths. A single hit can trigger both, so do not count them as mutually exclusive. The target hook is not cancellable through this Lua event table and does not report a previous target.
on_zone_enter / on_zone_leave are not fired by Lua zonesThese two hooks are accepted and validated by the loader, but nothing in a Lua power can make them fire. They are dispatched only from EliteScript's zone listener, which is started when a YAML eliteScript: on the same boss lists ZoneEnterEvent or ZoneLeaveEvent in its Events:. A boss with only Lua powers will load a file containing these hooks without complaint and then never call them.
The Lua watchers are a separate mechanism: context.zones:watch_zone(...) and context.script:zone(...):watch(...) invoke the on_enter / on_leave callbacks you pass them directly, and never route through these top-level hooks. Use the watchers unless you are deliberately pairing a Lua power with a YAML zone script.
YAML zone events reach every matching power on that boss. They contain the entering or leaving entity, but no originating zone or script identifier. Multiple YAML listeners can therefore deliver several events for the same entity.
(NPC Lua scripts are different -- their context.zones is MagmaCore's, whose watch does dispatch on_zone_enter / on_zone_leave. See NPC Scripts.)
Typical Multi-Hook Power
A single Lua power can define as many hooks as it needs. Below is a skeleton that uses three hooks together:
return {
api_version = 1,
on_enter_combat = function(context)
-- Initialize per-fight state when combat begins
context.state.hit_count = 0
context.log:info("Combat started!")
end,
on_boss_damaged_by_player = function(context)
-- Track hits and trigger an ability every 5th hit
context.state.hit_count = (context.state.hit_count or 0) + 1
if context.state.hit_count % 5 ~= 0 then
return
end
if not context.cooldowns:check_local("counter_attack", 100) then
return
end
-- Fire a projectile back at the player
local origin = context.boss:get_location()
origin:add(0, 1, 0)
context.boss:summon_projectile(
"SMALL_FIREBALL", origin, context.player:get_location(), 1.5
)
end,
on_death = function(context)
-- Spawn an explosion particle on death
context.world:spawn_particle_at_location(
context.boss:get_location(), "EXPLOSION_EMITTER", 1
)
end
}
Event Data (context.event)
Some hooks receive a context.event table that exposes data about the game event that triggered the hook. The fields available depend on which hook is running.
Damage Hooks
Applies to on_boss_damaged, on_boss_damaged_by_player, on_reinforcement_damaged_by_player, on_boss_damaged_by_elite, and on_player_damaged_by_boss.
| Field / Method | Type | Description |
|---|---|---|
event.damage_amount | double | Damage snapshot when this event table was created |
event.get_damage_amount() | double | Read the current damage after any earlier modifications |
event.damage_cause | string | Spigot DamageCause name (e.g. "ENTITY_ATTACK", "PROJECTILE") |
event.damager | entity table | Raw Bukkit damager when the underlying event is damage by an entity, including that case in on_boss_damaged. For a projectile hit this is the projectile, not its shooter. |
event.projectile | entity table | The projectile entity, if the damager was a projectile. |
event.set_damage_amount(n) | — | Override the damage to a fixed value |
event.multiply_damage_amount(n) | — | Multiply the current damage by n |
event.cancel_event() | — | Cancel the damage event entirely |
on_boss_damaged_by_player = function(context)
-- Halve all projectile damage
if context.event.damage_cause == "PROJECTILE" then
context.event.multiply_damage_amount(0.5)
end
end
Call event.set_damage_amount(n) and event.multiply_damage_amount(n) with a dot as shown. These two setters read the first argument directly and do not remove a colon-call self argument. event:transfer_damage(target, amount) does support a colon call.
On player-to-elite damage hooks, event.entity identifies the damaged elite. In on_reinforcement_damaged_by_player, context.boss remains the summoner and context.player is the attacking player. The hook receives the reinforcement's original mutable damage event.
These hooks also expose event.is_damage_transfer and event:transfer_damage(target, amount). The target must resolve to another live elite that the owning boss is allowed to affect. The amount must be finite and nonnegative. A cancelled event, zero amount, or nested transfer returns false. A valid call submits a separate player-attributed hit and returns true; it does not subtract damage from the original hit or guarantee that another damage listener will allow the new hit. Use event.set_damage_amount(...) yourself when the mechanic should split damage instead of duplicating it. Transfers cannot recursively transfer again.
Spawn Hook
Applies to on_spawn.
| Field / Method | Type | Description |
|---|---|---|
event.spawn_reason | string | Spigot SpawnReason name |
event.cancel_event() | — | Cancel the spawn |
Death Hook
Applies to on_death.
| Field / Method | Type | Description |
|---|---|---|
event.entity | entity table | The dying entity |
Zone Hooks
Applies to on_zone_enter and on_zone_leave.
| Field / Method | Type | Description |
|---|---|---|
event.entity | entity table | The entity entering or leaving the zone |
Lua-created zone watchers do not populate context.event; they pass the entering/leaving entity directly to their callback.
Cancellable Events (General)
Any hook whose underlying game event is cancellable exposes event.cancel_event(). context.event is nil for on_game_tick, on_mind_action, and ordinary scheduler callbacks. Hooks such as on_heal have an underlying event but no dedicated heal fields in this table; check which members are documented before calling them.
The boss-specific event table does not provide the shared wrapper's cancel(), uncancel() or is_cancelled members. Cancelling an event stops dispatch to later powers on the same boss; it does not return from your current Lua function. Add return when the rest of that function should not run.
For full entity table fields, see Boss & Entities. For damage cause and spawn reason values, see Enums & Values.
Hook Execution Order
The boss's event dispatcher visits attached script powers in execution order. A Lua power's priority determines its position:
- Lower values run first (default is
0). - Equal priorities preserve the boss's power insertion order. Do not rely on a particular tie order across different loading paths.
return {
api_version = 1,
priority = -10, -- runs before most other powers
on_boss_damaged_by_player = function(context)
-- Record this power's last attacker before its later hooks run
context.state.last_attacker = context.player.uuid
end
}
YAML EliteScript powers participate in the same dispatcher with priority 0. A negative Lua priority runs before them; a positive one runs after them. This orders processing of one event on one boss, not separate Bukkit events or scheduled tasks. Already-cancelled events stop the dispatcher, so not every attached power necessarily receives the event. After a reinforcement's own powers run, its direct summoner receives the reinforcement-damage hook only if the event is still not cancelled.
Runtime Model
One runtime per power and boss
Each attached Lua power gets its own runtime for each boss. When that runtime starts, EliteMobs evaluates the loaded source in a fresh sandboxed environment and stores the returned table. When the boss despawns or is removed, the runtime is shut down. A boss with two Lua powers therefore has two independent state tables and environments.
This means:
- File-local variables and functions, as well as any Lua globals you define, are private to that power's runtime on that boss.
- The returned table's hook functions are never shared between bosses.
The loader also evaluates the file once to validate its returned table. Keep file-scope initialization independent of hook context. A tick-only power does not need on_spawn to start its clock. Runtime initialization and the spawn callback are separate steps.
State Isolation
Each runtime has its own context.state table. It is separate from other powers on the same boss and from the same power on other bosses. Use it to store counters, flags, timers, or other per-power data across hooks. State lasts for the active runtime; it is not saved to disk across despawns or restarts.
return {
api_version = 1,
on_boss_damaged_by_player = function(context)
-- Each boss tracks its own enrage counter independently
context.state.enrage_hits = (context.state.enrage_hits or 0) + 1
if context.state.enrage_hits >= 20 then
context.boss:apply_potion_effect("SPEED", 200, 2)
end
end
}
Scheduled Task Ownership
All tasks created through context.scheduler are owned by the runtime that created them. When a boss despawns:
- The runtime calls
shutdown(). - Every owned task -- both one-shot (
run_after) and repeating (run_every) -- is automatically cancelled. - All zone watches are cleared.
- AI, awareness, scale and invulnerability changes this power still holds are restored, healing it set is cleared, and its tracking-fireball controller stops.
The runtime cleans up its owned scheduler tasks on boss removal. Some Java gameplay helpers start separate tasks with their own lifetimes, so check the helper's documentation. Cancel repeating tasks when they are no longer needed during normal gameplay:
return {
api_version = 1,
on_enter_combat = function(context)
local pulse_count = 0
local task_id
task_id = context.scheduler:run_every(20, function(tick_context)
pulse_count = pulse_count + 1
if pulse_count > 10 or not tick_context.boss.exists then
tick_context.scheduler:cancel_task(task_id)
return
end
tick_context.world:spawn_particle_at_location(
tick_context.boss:get_location(),
{ particle = "FLAME", amount = 20, speed = 0.1 }
)
end)
end
}
Per-Tick Clock Behavior
The internal tick clock for a Lua power instance only runs when the power defines an on_game_tick hook. Zone watches created through context.zones:watch_zone(...) or context.script:zone(...):watch(...) create their own owned repeating tasks instead of enabling the power's top-level on_game_tick hook.
Without on_game_tick or zone watchers, the runtime does not register that tick clock. Explicit scheduler tasks and tasks started by gameplay helpers still run on their own schedules. Tick work and owned zone watcher tasks are cancelled when the boss despawns or the runtime shuts down.
Pausing a runtime preserves its Lua state and owned tasks while skipping their work. The boss event path also checks power suppression. Removing the boss or closing the runtime ends its owned work instead of merely pausing it.
Error and Performance Behavior
EliteMobs enforces strict error and performance limits on Lua powers:
Exceptions
If a hook function or a scheduled callback throws a Lua error (or a Java exception surfaces from an API call), the power is immediately disabled for that boss instance. The runtime is shut down and all owned tasks are cancelled.
The failed instance stays closed; later damage hooks do not silently recreate it. Fix and reload the definition, then create a fresh runtime. Deliberate owner teardown can clear the old runtime reference for later body rehydration.
The error is logged to the server console along with the filename of the power and the hook that was running. A line number is included when available:
[Lua] Error in 'frost_cone.lua' at line 35 during 'on_boss_damaged_by_player':
[Lua] -> ...explanation of what went wrong...
[Lua] -> Script has been disabled for this entity to prevent further errors.
Execution Budget
Lua powers run under a hard execution budget enforced inside the Lua VM, not just measured afterwards. Every hook, every scheduled callback and even the initial evaluation of the file runs inside that budget:
| Limit | Value |
|---|---|
| Thread CPU time per entry point, when supported | 50 ms |
| Elapsed-time fallback when thread CPU time is unavailable | 250 ms |
| Bytecode instructions per entry point | 250,000 |
The VM counts instructions as it executes and checks time every 1024 instructions. The console identifies the exceeded limit:
Lua instruction budget exceeded (250000 instruction limit)
Lua CPU-time budget exceeded (50ms current-thread CPU limit)
Lua elapsed-time fallback budget exceeded (250ms fallback; current-thread CPU time unavailable)
An infinite Lua loop such as while true do end is aborted during execution. Nested Lua work shares the outer entry point's budget. The VM checks cannot interrupt a Java API call while that call is running, so the budget does not make expensive world operations safe. There is no separate post-return 50 ms wall-clock cutoff.
To stay within budget:
- Avoid unbounded loops inside hooks. Use
context.scheduler:run_every(...)to spread work across ticks. - Keep
on_game_tickhandlers lightweight -- they run every single tick. - Move heavy initialization into
on_spawnoron_enter_combatrather than repeating it every tick. - Remember that particle loops over large zones are the usual way to burn 250,000 instructions. Use
coverageto sample fewer locations.
Lua Sandbox
Lua powers run inside a sandboxed LuaJ environment. Several globals that could access the filesystem or the Java runtime are removed.
Removed Globals
The following standard Lua globals are set to nil and cannot be used:
| Removed | Why |
|---|---|
debug | Exposes internal VM state |
dofile | Filesystem access |
io | Filesystem access |
load | Arbitrary code loading |
loadfile | Filesystem access |
luajava | Direct Java class access |
module | Module system (not needed) |
os | Operating system access |
package | Module system (not needed) |
require | Module system / filesystem access |
The VM's debug hooks are still installed internally -- that is how the execution budget counts instructions -- but the debug table itself is set to nil immediately afterwards, so scripts cannot reach it.
Available Standard Library
The sandbox retains LuaJ's standard libraries, including these commonly used functions:
| Category | Functions |
|---|---|
| Math | math.abs, math.ceil, math.floor, math.max, math.min, math.random, math.sin, math.cos, math.sqrt, math.pi, and all other math.* functions |
| String | string.byte, string.char, string.find, string.format, string.gsub, string.len, string.lower, string.match, string.rep, string.sub, string.upper, and all other string.* functions |
| Table | table.insert, table.remove, table.sort, table.concat, and all other table.* functions |
| Iterators | pairs, ipairs, next |
| Type | type, tostring, tonumber, select, unpack |
| Error handling | pcall, xpcall, error, assert |
| Other | print, rawget, rawset, rawequal, rawlen, setmetatable, getmetatable |
Strings share one locked metatable. getmetatable("") returns false, and method calls on strings such as ("text"):upper() always use the standard string functions, even after a script adds or replaces functions in its own string table.
print writes to the server console, but prefer context.log:info(msg) or context.log:warn(msg) for output. Boss logging does not automatically include the power filename. Add that name to your message when several powers may log at once.
em Helper Namespace
The em table is available at file load time (before any hook runs). It provides helper constructors for building location tables, vector tables, and zone definitions used throughout the API.
| Function | Purpose |
|---|---|
em.create_location(x, y, z [, world, yaw, pitch]) | Create a location table with optional world name, yaw, and pitch |
em.create_vector(x, y, z) | Create a vector table |
em.zone.create_sphere_zone(radius) | Create a sphere zone definition |
em.zone.create_dome_zone(radius) | Create a dome zone definition |
em.zone.create_cylinder_zone(radius, height) | Create a cylinder zone definition |
em.zone.create_cuboid_zone(x, y, z) | Create a cuboid zone definition |
em.zone.create_cone_zone(length, radius) | Create a cone zone definition |
em.zone.create_static_ray_zone(length, thickness) | Create a static ray zone definition |
em.zone.create_rotating_ray_zone(length, point_radius, animation_duration) | Create a rotating ray zone definition |
em.zone.create_translating_ray_zone(length, point_radius, animation_duration) | Create a translating ray zone definition |
em also carries an em.location sub-table with shared read-only world queries: is_in_dungeon, is_protected, owned_by, owners, kinds_at and has_kind. They take a location table as their first argument. A missing or non-table location, missing world, or unloaded world returns false or an empty table. Missing coordinates default to zero; invalid coordinate types can still raise an error. Supply the world explicitly when constructing locations for these queries. Signatures and return shapes are documented in the Lua API Reference.
-- Skip a destructive mechanic inside claims or protected regions
if em.location.is_protected(context.player:get_location()) then return end
There is no Lua binding for build permission checks. em.location.is_protected tells you whether any protection plugin claims the location; it does not resolve per-player build rights.
Zone builders return chainable tables with :set_center(loc) (or :set_origin(loc) / :set_destination(loc) depending on zone type). These are designed to be used at the top of a file or inside hooks:
-- At file scope: create a reusable zone shape
local blast_zone = em.zone.create_sphere_zone(5)
return {
api_version = 1,
on_boss_damaged_by_player = function(context)
-- Anchor the zone to the boss's current location at call time
blast_zone:set_center(context.boss:get_location())
local entities = context.zones:get_entities_in_zone(blast_zone)
for i = 1, #entities do
if entities[i].is_player then
entities[i]:apply_potion_effect("SLOWNESS", 60, 1)
end
end
end
}
For a full breakdown of zone shapes, filters, watchers, and targeting patterns, see Zones & Targeting.
Next Steps
- Boss & Entities --
context.boss,context.player, entity wrappers - World & Environment -- particles, sounds, spawning,
context.world - Zones & Targeting -- native zones, script utilities,
context.zones/context.script - Examples & Patterns -- complete working powers you can study and adapt
- Enums & Values -- Spigot Javadoc links for all string constants
