Skip to main content

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.

webapp_banner.jpg


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​

  1. Zone creation -- context.script:zone(...) uses the same field names as EliteScript Zones. Target sets the cone tip one block above the boss. The first resolved Target2 position sets the base centre and therefore the cone's length; radius = 5 sets its base radius. NEARBY_PLAYERS searches a box extending 20 blocks on each axis and does not promise the nearest player. Use DIRECT_TARGET in this damage hook to aim at the attacker. No resolved player means no cone; endpoints must be distinct and in the same world.

  2. 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 fields particle, amount and speed. The particle helper shifts sampled zone locations by half a block on X and Z; particles are not exact entity-hit boundaries.

  3. Damage -- context.script:damage(cone:full_target(), 1.0, 1.5) hits resolved players inside the cones because the zone's default filter is PLAYER. 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.

EliteScript field names

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​

  1. State initialization -- on_spawn sets context.state.started to false and context.state.loop_task_id to nil. The state table survives across hooks for this power's runtime on this boss. Other powers have separate state tables.

  2. Combat guard -- on_enter_combat checks context.state.started before starting the loop. This prevents multiple overlapping loops if the event fires more than once.

  3. 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. Use run_repeating(100, 100, callback) if that first delay is wanted. The callback receives a fresh context without the triggering event or player, so loop_context.boss is a fresh snapshot. Store the returned numeric task ID in state. This example chooses an animation only; it does not itself deal damage.

  4. Cleanup on exit -- on_exit_combat cancels the repeating task using the stored task ID and resets state. This is critical: without cleanup, the scheduler keeps running even after combat ends.

Always cancel scheduler tasks

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​

  1. Nil guard -- context.player resolves 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.

  2. 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 returns false and 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.

  3. 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.

  4. Particles -- context.world:spawn_particle_at_location(location, spec) spawns particles at a specific location. The spec table accepts particle (Bukkit particle enum name), amount, and speed.

  5. Message -- context.player:send_message(text) sends a color-coded chat message. Standard Minecraft color codes like &c (red) work automatically.

  6. 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 call global_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​

  1. State setup -- on_spawn initializes aoe_task_id to nil. This task ID will hold the repeating scheduler reference.

  2. Repeating attack -- on_enter_combat starts 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 sets pulse_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.

  3. Zone definition -- The zone_def table uses native Lua zone syntax. The kind field specifies the shape ("sphere"), radius sets the size, and origin is set to the boss's current location at the moment the callback runs. This means the zone follows the boss as it moves.

  4. 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.

  5. Dealing damage -- victim:deal_custom_damage(4.0) submits 4 base damage attributed to the boss through BossCustomAttackDamage. 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 as context.script:damage.

  6. Particle feedback -- Purple dust particles appear at each queried player's stored location, even if their damage was rejected. A WITCH particle 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 at 1.

  7. Cleanup -- on_exit_combat cancels the repeating task and clears state, following the same pattern as the previous example.

Native zones vs. script utility zones

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​

  1. State initialization -- on_spawn sets phase to 1 and attack_task_id to nil. These values persist across this power runtime's hooks, not across a reload or in other powers.

  2. Attack loop -- on_enter_combat resumes the current phase through start_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 its phase_attack cooldown 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.

  3. Phase check in on_game_tick -- on_game_tick returns outside combat, then checks a 20-tick cooldown ("phase_check"). If the boss is already in phase 2, it returns early.

  4. Health threshold -- context.boss.health / context.boss.maximum_health gives the current health as a fraction. When it drops to 50% or below, the transition begins.

  5. 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).

  6. Phase 2 attacks -- phase_two_attack uses 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.

  7. Logging -- context.log:info(...) writes to the server console, which is invaluable for debugging phase transitions during development.

  8. Cleanup -- on_exit_combat cancels whatever attack loop is currently active, regardless of which phase the boss is in.

Keep on_game_tick lightweight

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) with kind, radius, origin. Script utilities use context.script:zone(...) with EliteScript field names like shape, 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 check global_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_every has zero initial delay; run_repeating takes 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_combat to 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 in on_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:

  1. The file returns exactly one table with api_version = 1.
  2. Every hook name matches an entry in the hooks list exactly (e.g., on_player_damaged_by_boss, not on_player_hit).
  3. context.player is guarded with == nil before use in hooks where it can be nil.
  4. context.state fields are initialized in on_spawn.
  5. Every combat-only repeating task, whether created with run_every or run_repeating, has a matching cancellation in on_exit_combat; its first-delay behavior is intentional.
  6. Scheduler callbacks use their own context and do not assume a triggering player or event is present.
  7. Cooldown keys include a power-name prefix and durations are in ticks. Global coordination includes an explicit readiness check.
  8. on_game_tick hooks gate expensive work behind a cooldown check.
  9. All method names exist in the API Reference -- no invented aliases.
  10. Script utility tables use EliteScript field names (targetType, shape, Target, etc.), not Lua-style names.
  11. Native zone definitions use kind, radius, origin, destination, etc.
  12. The power does not call any blocking or long-running operations inside a hook or callback.
  13. Particle specs use valid Bukkit particle enum names in UPPER_CASE (e.g., "FLAME", "DUST", "EXPLOSION").
  14. 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_spawn that 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.state for 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_tick light. 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_repeating and run_after callbacks 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: boss context.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 context inside a scheduler callback. The outer context captures a snapshot at the time the hook ran. Inside a run_every or run_after callback, 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_every in on_enter_combat but 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 read context.state.phase in on_game_tick but never set it in on_spawn, it will be nil and your comparisons will behave unexpectedly.

  • Using context.player without 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, and shape. Underscore alternatives such as target_type and zone_shape are not supported. The zone parser accepts lowercase target and target2, but that does not make arbitrary Lua-style aliases valid.

  • Running heavy logic in on_game_tick without 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) and player: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.health to set health. context.boss.health is a read-only snapshot. To heal the boss, use context.boss:restore_health(amount).

  • Forgetting api_version = 1. The returned table must include this field, or EliteMobs will not load the power.


Next Steps​