Lua Scripting: Examples & Patterns
This page contains full working examples of EliteMobs Lua powers, plus practical patterns, best practices, and troubleshooting tips. Each example includes a walkthrough explaining what it does and why.
If you're new to Lua powers, start with Getting Started. For full API details, see the API Reference.
Example: Reusing EliteScript Zones and Targets From Lua
What this teaches: How to use context.script to create EliteScript-style zone geometry from Lua, spawn particles, and damage entities in the zone.
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(...)creates a cone shape using the same field names as EliteScript Zones.Targetsets the cone origin (the boss itself, offset 1 block up), andTarget2sets the destination (the nearest players within 20 blocks).radiuscontrols how wide the cone opens. -
Particle spawning --
cone:full_target(0.4)returns a target handle that resolves to all locations inside the cone with 40% coverage (randomly samples 40% of zone points each call). The particle spec uses the same field names as EliteScript particles:particle,amount, andspeed. -
Damage --
context.script:damage(cone:full_target(), 1.0, 1.5)hits all living entities inside the full cone. The first number (1.0) is the base damage amount, and the second (1.5) is the damage multiplier applied to players.
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.
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 persists for the entire lifetime of this boss instance, so values set here survive across hooks. -
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)runs the callback every 100 ticks (5 seconds). The callback receives a fresh context, soloop_context.bossgives you the latest boss snapshot. The scheduler returns a numeric task ID that you store in state. -
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.
If you start a repeating task in on_enter_combat, always cancel it in on_exit_combat. Forgetting to cancel leaves a background task running until the boss despawns, which wastes performance and can cause unexpected behavior.
Example: On-Hit Fire Effect
What this teaches: A simple "on hit apply effect" power -- the most common pattern for combat powers.
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
-- 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
-- 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!")
-- Set the global power cooldown so other powers on this boss
-- don't all fire at the same instant
context.cooldowns:set_global(40)
end
}
Walkthrough
-
Nil guard --
context.playeris a lazy key that resolves to the player involved in the event. In rare edge cases (e.g., the player disconnected between the event firing and the hook running), it can benil. Always guard before using it. -
Local cooldown --
context.cooldowns:check_local("fire_touch", 60)does two things atomically: it checks whether the cooldown key"fire_touch"is ready, and if it is, it immediately sets the cooldown to 60 ticks. If the cooldown is not ready, it returnsfalseand the function exits early. The key"fire_touch"is scoped to this boss instance -- other bosses with the same power have independent cooldowns. -
Fire ticks --
context.player:set_fire_ticks(60)sets the player on fire for 60 game ticks (3 seconds). This calls the underlying Bukkit method directly. -
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)puts all powers on this boss on a 40-tick (2 second) cooldown. This prevents multiple powers from triggering simultaneously.
Example: Zone-Based AoE Power Using Native Lua Zones
What this teaches: Creating and querying native Lua zones to damage players in an area.
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
-- Check a local cooldown so this doesn't stack with other effects
if not tick_context.cooldowns:check_local("pulse_aoe", 60) then
return
end
-- 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 visual ring particles at the boss
tick_context.world:spawn_particle_at_location(
tick_context.boss:get_location(),
{ particle = "SPELL_MOB", amount = 40, speed = 0.1 }
)
-- Set global cooldown
tick_context.cooldowns:set_global(60)
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 repeating task every 60 ticks (3 seconds). The guard at the top prevents starting a second loop if combat re-enters. -
Zone definition -- The
zone_deftable uses native Lua zone syntax (not the EliteScript bridge). 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 Lua array of all player tables inside the sphere. Thefilteroption accepts"players","elites","mobs", or"living"(default). -
Dealing damage --
victim:deal_custom_damage(4.0)deals 4 points of damage attributed to the boss. This uses the EliteMobs custom damage system, which respects armor and other combat modifiers. -
Particle feedback -- Purple dust particles appear on each victim, and ambient particles appear at the boss's position to signal the pulse visually.
-
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 EliteScript bridge (context.script:zone()) uses EliteScript field names like shape, Target, and Target2. Both work -- use native zones for simple shapes and the bridge 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.
local function phase_one_attack(context)
-- Slow, heavy slam
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)
-- Fast, frantic multi-hit
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
return {
api_version = 1,
on_spawn = function(context)
context.state.phase = 1
context.state.attack_task_id = nil
context.state.phase_switched = false
end,
on_enter_combat = function(context)
if context.state.attack_task_id ~= nil then
return
end
-- Start the phase 1 attack loop: every 100 ticks (5 seconds)
context.state.attack_task_id = context.scheduler:run_every(100, function(tick_context)
if not tick_context.boss.exists then
return
end
if not tick_context.cooldowns:check_local("phase_attack", 100) then
return
end
phase_one_attack(tick_context)
end)
end,
on_game_tick = function(context)
-- Only check phase transition every 20 ticks (1 second) to stay lightweight
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.state.phase_switched = true
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 a faster phase 2 attack loop: every 40 ticks (2 seconds)
context.state.attack_task_id = context.scheduler:run_every(40, function(tick_context)
if not tick_context.boss.exists then
return
end
if not tick_context.cooldowns:check_local("phase_attack", 40) then
return
end
phase_two_attack(tick_context)
end)
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_spawnsetsphaseto1,attack_task_idtonil, andphase_switchedtofalse. These values persist across all hooks for this boss instance. -
Phase 1 attack loop --
on_enter_combatstarts a repeating task that firesphase_one_attackevery 100 ticks. The attack creates a 5-block sphere, damages players inside, and plays a slam animation with explosion particles. -
Phase check in on_game_tick --
on_game_tickfires every single server tick, so the first thing it does is check a 20-tick cooldown ("phase_check") to avoid running expensive logic every tick. 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 a larger sphere (8 blocks), deals slightly less damage per hit but fires much more frequently, applies a slowness potion effect, and uses red dust particles and a wither sound for a different feel. -
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.
on_game_tick runs every server tick (20 times per second). Always gate expensive work behind a cooldown check, as shown with check_local("phase_check", 20). If your hook exceeds 50ms, EliteMobs will automatically disable the power.
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 EliteScript bridge -- specify which one. Native zones use
context.zones:get_entities_in_zone(zone_def, opts)withkind,radius,origin. The bridge usescontext.script:zone(...)with EliteScript field names likeshape,Target,Target2. - Local and global cooldowns -- specify the cooldown key name and duration in ticks.
context.cooldowns:check_local(key, ticks)for per-boss cooldowns,context.cooldowns:set_global(ticks)for the shared power cooldown. - 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. - Only use documented method names -- if it is not on the API Reference page, it does not exist.
- Bridge specs use EliteScript field names --
targetType,shape,Target,Target2,range,offset,coverage, not Lua-style names. - Scheduler callbacks accept fresh context -- the callback parameter is a new context, not the outer one. Always use
tick_context(or whatever you name the parameter) inside callbacks.
Good prompt example
Write a Lua power that uses
on_enter_combatto start a repeating task every 80 ticks. Each tick, create a native Lua sphere zone (radius 6) centered on the boss, query players inside it, and deal 2.0 custom damage to each. Use a local cooldown key"pulse"with duration 80. Cancel the task inon_exit_combat. Spawn DUST particles (red=0, green=255, blue=100) at each victim.
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
context.scheduler:run_every(...)call has a matchingcontext.scheduler:cancel_task(...)inon_exit_combat. - Scheduler callbacks use the callback's own context parameter, not the outer
context. - Cooldown keys are descriptive strings (e.g.,
"fire_pulse") and durations are in ticks. on_game_tickhooks gate expensive work behind a cooldown check.- All method names exist in the API Reference -- no invented aliases.
- EliteScript bridge 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").
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. This keeps them out of global scope and avoids collisions with other Lua powers loaded in the same runtime. -
Put geometry into the EliteScript bridge. If you need cones, rotating rays, translating rays, or animated zones, use
context.script:zone(...)with EliteScript field names. The bridge reuses the battle-tested EliteScript zone engine. -
Use
context.statefor runtime state. Do not use Lua global variables.context.stateis scoped to a single boss instance and persists across hooks for the lifetime of that boss. -
Use named local cooldown keys. Instead of bare numbers, use descriptive keys like
"fire_touch"or"aoe_pulse". This makes debugging easier and prevents accidental collisions between different cooldowns in the same power. -
Keep
on_game_ticklight. Always gate it behind a cooldown check. If your logic runs every tick, it must complete in well under 50ms or the power will be disabled. -
Cancel repeating tasks when done. Every
run_everymust have a matchingcancel_taskinon_exit_combat(and possiblyon_death). Leaked tasks waste CPU and can cause null reference errors after the boss despawns. -
Use fresh scheduler callback contexts. Scheduler callbacks (
run_every,run_after) receive a fresh context as their parameter. Always use that parameter -- not the outercontext-- because the outer context may hold stale snapshots. -
Log state transitions with
context.log:info(). During development, add logging for phase switches, cooldown starts, and scheduler starts/stops. Remove or change tocontext.log:debug()before deploying. -
Reuse existing EliteScript docs. The Zones, Targets, Relative Vectors, and Conditions pages document the same field names the bridge accepts. Do not duplicate that information in your Lua power -- just reference it.
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. -
Checking
context.playerwithout a nil guard. In hooks likeon_player_damaged_by_boss, the player is almost always available -- but "almost always" is not "always". A single missing nil guard can crash the power. -
Using Lua-style field names in the EliteScript bridge. The bridge expects
targetType,Target,Target2,shape-- nottarget_type,target,target2,zone_shape. Mismatched names silently produce no results. -
Running heavy logic in
on_game_tickwithout a cooldown gate. This hook fires every server tick. Even simple arithmetic repeated 20 times per second across many bosses adds up. -
Inventing method names. If a method is not listed in the API Reference, it does not exist. Common mistakes include writing
entity:teleport(loc)instead ofentity:teleport_to_location(loc), orplayer:set_velocity(vec)instead ofplayer:set_velocity_vector(vec). -
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.
Do Not Assume Undocumented Aliases Exist
The Lua API exposes a specific set of method names. If you are writing powers by hand or with AI assistance, do not assume shorthand or alternative names exist. The following are examples of names that do not exist and will cause errors:
show_temporary_boss_bar()-- useplayer:show_boss_bar(title, color, style, duration)instead.run_command_as_player()-- useplayer:run_command(command)instead.em.location(...)-- there is noemglobal. Usecontext.boss:get_location(),context.player.current_location, orcontext.worldmethods.em.vector(...)-- there is noemglobal. Usecontext.vectors.get_vector_between_locations(loc1, loc2)or plain{x=0, y=1, z=0}tables.em.zone.sphere(...)-- there is noemglobal. Use a zone definition table like{kind = "sphere", radius = 5, origin = location}.entity:teleport_to(...)-- useentity:teleport_to_location(location).entity:set_velocity(...)-- useentity:set_velocity_vector(vector).entity:set_facing(...)-- useentity:face_direction_or_location(direction_or_location).
When in doubt, check the API Reference. If it is not documented there, it does not exist.
Troubleshooting
1. Power does not load at all
Check the server console for errors when the server starts. The most common cause is a Lua syntax error (missing end, unmatched parentheses, etc.). Also verify the file ends in .lua and is placed in the correct powers directory.
2. Hook never fires
Verify the hook name is spelled exactly as listed in the hooks list. Common mistakes: on_boss_hit (wrong) vs. on_boss_damaged_by_player (correct), or on_tick (wrong) vs. on_game_tick (correct).
3. context.player is nil
Not all hooks provide a player. on_spawn, on_game_tick, on_enter_combat, and on_exit_combat do not have a player. In on_boss_damaged (generic damage), the damager may not be a player. Always add a nil guard before using context.player.
4. "Exceeded the 50ms execution budget" warning
Your hook or callback took too long. The power is automatically disabled. Common causes: iterating over too many entities, creating too many zones per tick, or running expensive string operations in on_game_tick. Move expensive work behind a cooldown gate or reduce the work done per call.
5. Scheduler callback uses stale data
You are probably using the outer context instead of the callback parameter. Change function() ... context.boss ... end to function(tick_context) ... tick_context.boss ... end.
6. Zone query returns no entities
Double-check the zone definition. For native zones, ensure kind is lowercase ("sphere", not "SPHERE"). For the EliteScript bridge, ensure shape is uppercase ("CONE", not "cone"). Also verify that origin or Target actually resolves to a valid location.
7. Particles do not appear
Verify the particle name is a valid Bukkit Particle enum value in UPPER_CASE. Common mistake: "flame" (wrong) vs. "FLAME" (correct). Also check that amount is at least 1 and the location is in a loaded chunk.
8. Cooldown does not seem to work
Make sure you are using check_local(key, duration) (which checks AND sets in one call), not local_ready(key) followed by a separate set_local(duration, key). If you use local_ready alone, you only check but never set the cooldown.
9. Boss keeps running power after death
Add cleanup logic in on_exit_combat and/or on_death to cancel scheduler tasks. If the boss dies, on_exit_combat should fire, but adding explicit cleanup in both hooks is safer.
