Lua Scripting: Examples & Patterns
This page contains complete EliteMobs Lua power examples and walkthroughs. Save each example as its own .lua file, attach it to a test boss, run /em reload, and spawn a fresh boss after edits. Animation names such as slam, roar, frenzy and transform must exist on the boss's custom model; they are not built-in animations. A boss without a custom model skips these animation calls. Times below assume 20 TPS.
If you're new to Lua powers, start with Getting Started. For NPC scripts, see NPC Scripts. For full boss power API details, see the API Reference, Boss & Entities, World & Environment, Zones & Targeting, and Enums.
Example: Zone-Based Targeting with Script Utilities
What this teaches: How to use context.script to create EliteScript-style zone geometry from Lua, spawn particles, and damage entities in the zone.
Full power file (click to expand)
return {
api_version = 1,
on_boss_damaged_by_player = function(context)
local cone = context.script:zone({
shape = "CONE",
Target = {
targetType = "SELF",
offset = "0,1,0"
},
Target2 = {
targetType = "NEARBY_PLAYERS",
range = 20
},
radius = 5
})
context.script:spawn_particles(
cone:full_target(0.4),
{
particle = "FLAME",
amount = 1,
speed = 0.05
}
)
context.script:damage(cone:full_target(), 1.0, 1.5)
end
}
Walkthrough
-
Zone creation --
context.script:zone(...)uses the same field names as EliteScript Zones.Targetsets the cone tip one block above the boss. The first resolvedTarget2position sets the base centre and therefore the cone's length;radius = 5sets its base radius.NEARBY_PLAYERSsearches a box extending 20 blocks on each axis and does not promise the nearest player. UseDIRECT_TARGETin this damage hook to aim at the attacker. No resolved player means no cone; endpoints must be distinct and in the same world. -
Particle spawning --
cone:full_target(0.4)returns a target handle that randomly samples zone points with a 40% selection probability on each call. This is not a guaranteed exact count and does not filter damage recipients. Particle specs use the EliteScript fieldsparticle,amountandspeed. The particle helper shifts sampled zone locations by half a block on X and Z; particles are not exact entity-hit boundaries. -
Damage --
context.script:damage(cone:full_target(), 1.0, 1.5)hits resolved players inside the cones because the zone's default filter isPLAYER. The first number (1.0) is the submitted base damage amount, and the second (1.5) is the EliteMobs damage multiplier applied to players. The combat pipeline determines final damage.
This example runs on every dispatched hit: it has no local or global cooldown and no warning delay between particles and damage. Add explicit cooldown checks if combining it with other attacks. Each static-zone query resolves its targets again; a Lua zone handle does not automatically apply YAML action conditions or target caching.
The zone and target tables passed to context.script use EliteScript field names (targetType, shape, Target, Target2, range, offset, coverage). For the full list, see EliteScript Zones and EliteScript Targets.
Example: State + Scheduler Attack Loop
What this teaches: Using context.state to track runtime state, context.scheduler for repeating tasks, and proper combat enter/exit lifecycle.
Full power file (click to expand)
local function pick_action(context)
local roll = math.random(1, 2)
if roll == 1 then
context.boss:play_model_animation("slam")
else
context.boss:play_model_animation("roar")
end
end
return {
api_version = 1,
on_spawn = function(context)
context.state.started = false
context.state.loop_task_id = nil
end,
on_enter_combat = function(context)
if context.state.started then
return
end
context.state.started = true
context.state.loop_task_id = context.scheduler:run_every(100, function(loop_context)
if loop_context.boss.exists then
pick_action(loop_context)
end
end)
end,
on_exit_combat = function(context)
if context.state.loop_task_id ~= nil then
context.scheduler:cancel_task(context.state.loop_task_id)
context.state.loop_task_id = nil
end
context.state.started = false
end
}
Walkthrough
-
State initialization --
on_spawnsetscontext.state.startedtofalseandcontext.state.loop_task_idtonil. Thestatetable survives across hooks for this power's runtime on this boss. Other powers have separate state tables. -
Combat guard --
on_enter_combatcheckscontext.state.startedbefore starting the loop. This prevents multiple overlapping loops if the event fires more than once. -
Scheduler pattern --
context.scheduler:run_every(100, callback)schedules its first call with zero delay, then repeats every 100 ticks (5 seconds). It does not wait five seconds before starting. Userun_repeating(100, 100, callback)if that first delay is wanted. The callback receives a fresh context without the triggering event or player, soloop_context.bossis a fresh snapshot. Store the returned numeric task ID in state. This example chooses an animation only; it does not itself deal damage. -
Cleanup on exit --
on_exit_combatcancels the repeating task using the stored task ID and resets state. This is critical: without cleanup, the scheduler keeps running even after combat ends.
Cancel combat-only repeating tasks in on_exit_combat. Without that cleanup they remain scheduled after combat ends, until cancelled or the power runtime closes. Paused runtimes skip callbacks without losing the task. An uncaught callback error closes the runtime and cancels its owned tasks.
Example: On-Hit Fire Effect
What this teaches: A simple "on hit apply effect" power -- the most common pattern for combat powers.
Full power file (click to expand)
return {
api_version = 1,
on_player_damaged_by_boss = function(context)
-- Guard: the player may be nil in edge cases
if context.player == nil then
return
end
if not context.cooldowns:global_ready() then
return
end
-- Check and set a 60-tick (3 second) local cooldown in one call
if not context.cooldowns:check_local("fire_touch", 60) then
return
end
-- Coordinate with other powers that check the shared cooldown
context.cooldowns:set_global(40)
-- Set the player on fire for 60 ticks (3 seconds)
context.player:set_fire_ticks(60)
-- Visual feedback: spawn flame particles at the player's location
context.world:spawn_particle_at_location(
context.player.current_location,
{ particle = "FLAME", amount = 20, speed = 0.1 }
)
-- Tell the player what happened
context.player:send_message("&cThe boss's touch burns!")
end
}
Walkthrough
-
Nil guard --
context.playerresolves from the current event's player actor or direct target. Guard before using it, especially when adapting this code to another hook. Scheduler callbacks do not inherit this player. -
Local cooldown -- After checking
global_ready(),context.cooldowns:check_local("fire_touch", 60)checks its key and immediately sets a 60-tick cooldown if ready. Otherwise it returnsfalseand the hook exits. The key is shared by powers on this boss; different bosses are independent. Prefix keys with a power name when combining examples. -
Fire ticks --
context.player:set_fire_ticks(60)sets the Bukkit fire duration to 60 ticks (3 seconds), after checking that the player is valid, alive, online, in the source world and, inside a dungeon instance, a current participant. It replaces the duration rather than adding 60 ticks. Other game mechanics can extinguish the player; the message and particles are not proof that the effect was accepted. -
Particles --
context.world:spawn_particle_at_location(location, spec)spawns particles at a specific location. The spec table acceptsparticle(Bukkit particle enum name),amount, andspeed. -
Message --
context.player:send_message(text)sends a color-coded chat message. Standard Minecraft color codes like&c(red) work automatically. -
Global cooldown --
context.cooldowns:set_global(40)sets the boss's shared cooldown for 40 ticks (2 seconds), before applying effects. This example checks it before consuming its local cooldown. Other Lua hooks and callbacks must also callglobal_ready()if they should wait; setting the global cooldown does not suspend every Lua callback.
Example: Zone-Based AoE Power Using Native Lua Zones
What this teaches: Creating and querying native Lua zones to damage players in an area.
Full power file (click to expand)
return {
api_version = 1,
on_spawn = function(context)
context.state.aoe_task_id = nil
end,
on_enter_combat = function(context)
-- Prevent duplicate loops
if context.state.aoe_task_id ~= nil then
return
end
context.state.aoe_task_id = context.scheduler:run_every(60, function(tick_context)
-- Make sure the boss is still alive
if not tick_context.boss.exists then
return
end
if not tick_context.cooldowns:global_ready() then
return
end
-- Check and set this boss's named cooldown before applying effects
if not tick_context.cooldowns:check_local("pulse_aoe", 60) then
return
end
tick_context.cooldowns:set_global(60)
-- Build a sphere zone centered on the boss's current position
local zone_def = {
kind = "sphere",
radius = 8,
origin = tick_context.boss:get_location()
}
-- Find all players inside the sphere
local victims = tick_context.zones:get_entities_in_zone(zone_def, { filter = "players" })
-- Damage and show particles on each player found
for i = 1, #victims do
local victim = victims[i]
victim:deal_custom_damage(4.0)
tick_context.world:spawn_particle_at_location(
victim.current_location,
{ particle = "DUST", amount = 15, speed = 0, red = 128, green = 0, blue = 255 }
)
end
-- Spawn a particle burst at the boss; this does not draw the sphere
tick_context.world:spawn_particle_at_location(
tick_context.boss:get_location(),
{ particle = "WITCH", amount = 40, speed = 0.1 }
)
end)
end,
on_exit_combat = function(context)
if context.state.aoe_task_id ~= nil then
context.scheduler:cancel_task(context.state.aoe_task_id)
context.state.aoe_task_id = nil
end
end
}
Walkthrough
-
State setup --
on_spawninitializesaoe_task_idtonil. This task ID will hold the repeating scheduler reference. -
Repeating attack --
on_enter_combatstarts a task with zero initial delay, repeating every 60 ticks (3 seconds). The guard prevents duplicate loops. Each callback checks the global cooldown, then checks and setspulse_aoe, then sets a 60-tick global cooldown before effects. This coordinates only with powers that check those keys; it does not block all scripts automatically. -
Zone definition -- The
zone_deftable uses native Lua zone syntax. Thekindfield specifies the shape ("sphere"),radiussets the size, andoriginis set to the boss's current location at the moment the callback runs. This means the zone follows the boss as it moves. -
Querying entities --
tick_context.zones:get_entities_in_zone(zone_def, { filter = "players" })returns a one-based Lua array using the shape's entity-intersection test, not an exact feet-position distance check. Queries search the boss's current world and do not filter players by game mode or encounter participation. Filters accept"players","elites","mobs"or"living"(default); the boss itself is excluded. -
Dealing damage --
victim:deal_custom_damage(4.0)submits 4 base damage attributed to the boss throughBossCustomAttackDamage. It skips invulnerable/dead targets and players outside Survival or Adventure. On the elite-to-player path, it bypasses the normal damage formula and blocking reduction, while vanilla armor reductions are cleared. Damage listeners, cancellation and absorption can still change health loss. The helper resets the target's native damage cooldown after submission; it is not the same path ascontext.script:damage. -
Particle feedback -- Purple dust particles appear at each queried player's stored location, even if their damage was rejected. A
WITCHparticle burst appears at the boss; it does not draw a ring or the sphere's boundary. These compact particle tables use zero position spread by default, and dust size is fixed at1. -
Cleanup --
on_exit_combatcancels the repeating task and clears state, following the same pattern as the previous example.
This example uses native Lua zones (context.zones:get_entities_in_zone()), which take a simple table with kind, radius, origin, etc. The script utilities (context.script:zone(...)) use EliteScript field names like shape, Target, and Target2. Both work -- use native zones for simple shapes and context.script when you need EliteScript's advanced target resolution.
Example: Multi-Phase Boss Mechanic
What this teaches: Using state to track boss phases and switching behavior at health thresholds.
These are Lua state phases, not EliteMobs YAML phases: that replace a boss definition. No boss is replaced or healed here. Healing later does not return this example to phase 1; leaving combat preserves phase 2 until the power runtime is recreated.
Full power file (click to expand)
local function phase_one_attack(context)
-- One damage pulse per callback; the animation does not delay it
context.boss:play_model_animation("slam")
local zone_def = {
kind = "sphere",
radius = 5,
origin = context.boss:get_location()
}
local targets = context.zones:get_entities_in_zone(zone_def, { filter = "players" })
for i = 1, #targets do
targets[i]:deal_custom_damage(3.0)
end
context.world:spawn_particle_at_location(
context.boss:get_location(),
{ particle = "EXPLOSION", amount = 3, speed = 0 }
)
end
local function phase_two_attack(context)
-- One pulse per callback, repeated more frequently in phase 2
context.boss:play_model_animation("frenzy")
local zone_def = {
kind = "sphere",
radius = 8,
origin = context.boss:get_location()
}
local targets = context.zones:get_entities_in_zone(zone_def, { filter = "players" })
for i = 1, #targets do
targets[i]:deal_custom_damage(2.0)
targets[i]:apply_potion_effect("SLOWNESS", 40, 1)
end
context.world:spawn_particle_at_location(
context.boss:get_location(),
{ particle = "DUST", amount = 30, speed = 0.2, red = 255, green = 0, blue = 0 }
)
context.world:play_sound_at_location(
context.boss:get_location(),
"entity.wither.ambient",
1.0, 1.5
)
end
local function start_attack_loop(context)
if context.state.attack_task_id ~= nil then return end
local phase = context.state.phase or 1
local interval = phase == 2 and 40 or 100
local attack = phase == 2 and phase_two_attack or phase_one_attack
context.state.attack_task_id = context.scheduler:run_every(interval, function(tick_context)
if not tick_context.boss.exists or not tick_context.boss.is_in_combat then return end
if tick_context.cooldowns:check_local("phase_attack", interval) then
attack(tick_context)
end
end)
end
return {
api_version = 1,
on_spawn = function(context)
context.state.phase = 1
context.state.attack_task_id = nil
end,
on_enter_combat = function(context)
start_attack_loop(context)
end,
on_game_tick = function(context)
-- Only check phase transition every 20 ticks (1 second) to stay lightweight
if not context.boss.is_in_combat then return end
if not context.cooldowns:check_local("phase_check", 20) then
return
end
-- Skip if already in phase 2
if context.state.phase ~= 1 then
return
end
-- Check health ratio
local health_ratio = context.boss.health / context.boss.maximum_health
if health_ratio <= 0.5 then
-- Transition to phase 2
context.state.phase = 2
context.log:info("Boss entering phase 2 at " .. tostring(math.floor(health_ratio * 100)) .. "% health")
-- Cancel the old attack loop
if context.state.attack_task_id ~= nil then
context.scheduler:cancel_task(context.state.attack_task_id)
context.state.attack_task_id = nil
end
-- Play transition effects
context.boss:play_model_animation("transform")
-- Announce the phase change to nearby players
local nearby = context.players.nearby_players(40)
for i = 1, #nearby do
nearby[i]:send_message("&4&lThe boss enters a frenzy!")
nearby[i]:show_title("&4Phase 2", "&cThe boss is enraged!", 10, 40, 10)
end
start_attack_loop(context)
end
end,
on_exit_combat = function(context)
if context.state.attack_task_id ~= nil then
context.scheduler:cancel_task(context.state.attack_task_id)
context.state.attack_task_id = nil
end
end
}
Walkthrough
-
State initialization --
on_spawnsetsphaseto1andattack_task_idtonil. These values persist across this power runtime's hooks, not across a reload or in other powers. -
Attack loop --
on_enter_combatresumes the current phase throughstart_attack_loop: phase 1 uses a 100-tick interval, phase 2 uses 40. Both schedule the first callback with zero delay. Each attack still needs itsphase_attackcooldown to be ready; switching phases does not clear the old cooldown, so a callback can skip an attack. This example does not check the global cooldown. Phase 1 submits 3 custom damage to players in a 5-block sphere and plays animation and particle effects without an animation-length delay. -
Phase check in on_game_tick --
on_game_tickreturns outside combat, then checks a 20-tick cooldown ("phase_check"). If the boss is already in phase 2, it returns early. -
Health threshold --
context.boss.health / context.boss.maximum_healthgives the current health as a fraction. When it drops to 50% or below, the transition begins. -
Phase transition -- The old attack loop is cancelled, a transformation animation plays, nearby players receive a message and title, and a new faster attack loop starts (every 40 ticks instead of 100).
-
Phase 2 attacks --
phase_two_attackuses an 8-block sphere and submits one 2-damage hit per queried player per successful callback. It attempts Slowness II for 40 ticks (amplifier = 1), subject to the status-effect participation checks described above, then shows red dust and plays a wither sound. It does not schedule multiple hits within one callback. -
Logging --
context.log:info(...)writes to the server console, which is invaluable for debugging phase transitions during development. -
Cleanup --
on_exit_combatcancels whatever attack loop is currently active, regardless of which phase the boss is in.
An active, unpaused runtime receives on_game_tick every server tick (20 times per second at 20 TPS). Gate expensive work behind a cooldown, as with check_local("phase_check", 20). This polls health, so a lethal hit can remove the boss before a transition check. Exceeding the execution budget closes that power runtime and cancels its tasks.
AI Generation Tips
If you want AI to generate Lua powers reliably, make sure the prompt includes:
- Exact hook name -- e.g.,
on_player_damaged_by_boss, not "when the boss hits a player". - Native Lua zones or script utilities -- specify which one. Native zones use
context.zones:get_entities_in_zone(zone_def, opts)withkind,radius,origin. Script utilities usecontext.script:zone(...)with EliteScript field names likeshape,Target,Target2. - Local and global cooldowns -- specify a namespaced key and duration in ticks.
context.cooldowns:check_local(key, ticks)checks and sets a named per-boss cooldown. To coordinate with the global cooldown, explicitly checkglobal_ready()before consuming the local key and set the global duration before effects. - Custom model animation names -- e.g.,
context.boss:play_model_animation("slam"). The AI cannot guess animation names; provide them. - Target selection -- specify whether the power targets
context.player(the event player),context.players.nearby_players(range), or a zone query. - Effect type -- damage (
deal_custom_damage), potion (apply_potion_effect), fire (set_fire_ticks), velocity (set_velocity_vector,apply_push_vector), etc. - Use the correct wrapper's method names -- provide the applicable boss or shared API reference and do not invent aliases.
- Script utility specs use EliteScript field names --
targetType,shape,Target,Target2,range,offset,coverage, not Lua-style names. - Scheduler timing and context -- specify whether the first callback is immediate or delayed.
run_everyhas zero initial delay;run_repeatingtakes a separate delay. Use the callback's fresh context; it has no triggering player or event. Explicitly capture a player UUID or location if the mechanic needs one later.
Good prompt example
Write a Lua power that uses
on_enter_combatto start a repeating task with an 80-tick first delay and 80-tick interval. On each callback, create a native Lua sphere of radius 6 centred on the boss, query players, and submit 2.0 custom damage to each. Check the global cooldown before checking and setting local key"my_power:pulse"for 80 ticks, then set the global cooldown to 80 before effects. Cancel the task inon_exit_combat. Spawn DUST particles (red=0, green=255, blue=100) at each queried player.
Extra constraints to include
- "Return a single table with
api_version = 1." - "Initialize all state fields in
on_spawn." - "Guard scheduler callbacks with
if not tick_context.boss.exists then return end." - "Cancel all repeating tasks in
on_exit_combat." - "Do not invent method names -- only use methods from the API Reference page."
- "Use
context.cooldowns:check_local(key, ticks)for combined check-and-set."
QC Checklist For Human Review Or AI Review
Use this checklist to verify a Lua power before deploying it:
- The file returns exactly one table with
api_version = 1. - Every hook name matches an entry in the hooks list exactly (e.g.,
on_player_damaged_by_boss, noton_player_hit). context.playeris guarded with== nilbefore use in hooks where it can be nil.context.statefields are initialized inon_spawn.- Every combat-only repeating task, whether created with
run_everyorrun_repeating, has a matching cancellation inon_exit_combat; its first-delay behavior is intentional. - Scheduler callbacks use their own context and do not assume a triggering player or event is present.
- Cooldown keys include a power-name prefix and durations are in ticks. Global coordination includes an explicit readiness check.
on_game_tickhooks gate expensive work behind a cooldown check.- All method names exist in the API Reference -- no invented aliases.
- Script utility tables use EliteScript field names (
targetType,shape,Target, etc.), not Lua-style names. - Native zone definitions use
kind,radius,origin,destination, etc. - The power does not call any blocking or long-running operations inside a hook or callback.
- Particle specs use valid Bukkit particle enum names in UPPER_CASE (e.g.,
"FLAME","DUST","EXPLOSION"). - Reload changed files, spawn a fresh boss, and check visible behavior and console output on a staging server. A parsed file alone does not prove the attack works.
Best Practices
-
Start with a tiny hook and verify. Write a single
on_spawnthat sends a log message. Confirm it fires. Then build from there. -
Keep helper functions local. Declare helpers like
local function pick_action(context)above the return table to keep dependencies explicit. Each power instance already has its own Lua runtime. -
Put geometry into script utilities. If you need cones, rotating rays, translating rays, or animated zones, use
context.script:zone(...)with EliteScript field names. The script utilities reuse the EliteScript zone engine. -
Use
context.statefor runtime state. It is scoped to one power runtime on one boss and persists across that runtime's hooks. It is not shared with other powers or saved across reloads. -
Use named local cooldown keys. Instead of bare numbers, use descriptive keys like
"fire_touch"or"aoe_pulse". Explicit keys are shared across all powers on the same boss. Prefix them with the power name, such as"my_power:fire_touch", to prevent collisions. -
Keep
on_game_ticklight. Gate expensive work behind a cooldown check and stay well below the execution budget. -
Cancel repeating tasks when done. Combat-only loops should be cancelled in
on_exit_combat. Owned scheduler tasks are cancelled automatically when the runtime closes, but continue after combat ends unless explicitly cancelled. -
Use fresh scheduler callback contexts.
run_every,run_repeatingandrun_aftercallbacks receive a fresh context without the event or player that scheduled them. Use that argument and explicitly retain any needed UUID or position; an outer context can hold stale snapshots. -
Log state transitions with
context.log:info(). During development, log phase switches, cooldown starts, and scheduler starts/stops. Remove noisy logs before deploying: bosscontext.log:debug()also writes at info level, with a different prefix. -
Reuse the relevant EliteScript references. Zones, Targets and Relative Vectors describe the shared parsers. YAML Conditions, waits and action-level caching are not automatically evaluated by standalone Lua helpers; express the checks in Lua. Inherited targets and landing locations need a YAML action context that these handles lack.
Common Beginner Mistakes
-
Using the outer
contextinside a scheduler callback. The outer context captures a snapshot at the time the hook ran. Inside arun_everyorrun_aftercallback, always use the callback's own parameter (e.g.,tick_context), which gives you a fresh snapshot. -
Forgetting to cancel repeating tasks. If you start a
run_everyinon_enter_combatbut never cancel it, the task runs until the boss is removed from the server, even after combat ends. -
Not initializing state in
on_spawn. If you readcontext.state.phaseinon_game_tickbut never set it inon_spawn, it will beniland your comparisons will behave unexpectedly. -
Using
context.playerwithout a nil guard. Check which hooks provide a player. Scheduled callbacks have none by default; a missing guard can close the power runtime. -
Using Lua-style field names in script utility calls. Use the documented EliteScript fields, such as
targetType,Target,Target2, andshape. Underscore alternatives such astarget_typeandzone_shapeare not supported. The zone parser accepts lowercasetargetandtarget2, but that does not make arbitrary Lua-style aliases valid. -
Running heavy logic in
on_game_tickwithout a cooldown gate. World-wide entity queries and dense particle geometry across many bosses are expensive. Gate that work and keep each callback within its execution budget. -
Mixing wrapper APIs. EliteMobs boss-specific wrappers use
entity:teleport_to_location(loc)andplayer:set_velocity_vector(vec). Shared MagmaCore wrappers have additional aliases. Check the context's own reference before borrowing a method from another plugin's example. -
Using
context.boss.healthto set health.context.boss.healthis a read-only snapshot. To heal the boss, usecontext.boss:restore_health(amount). -
Forgetting
api_version = 1. The returned table must include this field, or EliteMobs will not load the power.
Next Steps
- Getting Started -- file structure, hooks, first power walkthrough
- NPC Scripts -- NPC proximity and interaction scripts
- Hooks & Lifecycle -- full hook and context reference
- Boss & Entities -- boss, player, and entity methods
- World & Environment -- particles, sounds, lightning, blocks
- Zones & Targeting -- native zones and script utility zones
- Enums -- valid values for Particle, Sound, Material, and other string constants
- Troubleshooting -- common issues, debugging tips, and migration advice
