Saltar al contenido principal

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.

webapp_banner.jpg


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

  1. Zone creation -- context.script:zone(...) creates a cone shape using the same field names as EliteScript Zones. Target sets the cone origin (the boss itself, offset 1 block up), and Target2 sets the destination (the nearest players within 20 blocks). radius controls how wide the cone opens.

  2. 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, and speed.

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

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.

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 persists for the entire lifetime of this boss instance, so values set here survive across hooks.

  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) runs the callback every 100 ticks (5 seconds). The callback receives a fresh context, so loop_context.boss gives you the latest boss snapshot. The scheduler returns a numeric task ID that you store in state.

  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

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

  1. Nil guard -- context.player is 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 be nil. Always guard before using it.

  2. 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 returns false and the function exits early. The key "fire_touch" is scoped to this boss instance -- other bosses with the same power have independent cooldowns.

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

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

  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 repeating task every 60 ticks (3 seconds). The guard at the top prevents starting a second loop if combat re-enters.

  3. Zone definition -- The zone_def table uses native Lua zone syntax (not the EliteScript bridge). 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 Lua array of all player tables inside the sphere. The filter option accepts "players", "elites", "mobs", or "living" (default).

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

  6. Particle feedback -- Purple dust particles appear on each victim, and ambient particles appear at the boss's position to signal the pulse visually.

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

Native zones vs. EliteScript bridge zones

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

  1. State initialization -- on_spawn sets phase to 1, attack_task_id to nil, and phase_switched to false. These values persist across all hooks for this boss instance.

  2. Phase 1 attack loop -- on_enter_combat starts a repeating task that fires phase_one_attack every 100 ticks. The attack creates a 5-block sphere, damages players inside, and plays a slam animation with explosion particles.

  3. Phase check in on_game_tick -- on_game_tick fires 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.

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

  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

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) with kind, radius, origin. The bridge uses context.script:zone(...) with EliteScript field names like shape, 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_combat to 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 in on_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:

  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 context.scheduler:run_every(...) call has a matching context.scheduler:cancel_task(...) in on_exit_combat.
  6. Scheduler callbacks use the callback's own context parameter, not the outer context.
  7. Cooldown keys are descriptive strings (e.g., "fire_pulse") and durations are in ticks.
  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. EliteScript bridge 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").

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. 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.state for runtime state. Do not use Lua global variables. context.state is 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_tick light. 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_every must have a matching cancel_task in on_exit_combat (and possibly on_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 outer context -- 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 to context.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 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.

  • Checking context.player without a nil guard. In hooks like on_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 -- not target_type, target, target2, zone_shape. Mismatched names silently produce no results.

  • Running heavy logic in on_game_tick without 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 of entity:teleport_to_location(loc), or player:set_velocity(vec) instead of player:set_velocity_vector(vec).

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


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() -- use player:show_boss_bar(title, color, style, duration) instead.
  • run_command_as_player() -- use player:run_command(command) instead.
  • em.location(...) -- there is no em global. Use context.boss:get_location(), context.player.current_location, or context.world methods.
  • em.vector(...) -- there is no em global. Use context.vectors.get_vector_between_locations(loc1, loc2) or plain {x=0, y=1, z=0} tables.
  • em.zone.sphere(...) -- there is no em global. Use a zone definition table like {kind = "sphere", radius = 5, origin = location}.
  • entity:teleport_to(...) -- use entity:teleport_to_location(location).
  • entity:set_velocity(...) -- use entity:set_velocity_vector(vector).
  • entity:set_facing(...) -- use entity: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.