Lua Scripting: NPC Scripts
EliteMobs NPC Lua scripts are standalone .lua files that attach to NPC configs. They are separate from boss Lua powers: boss powers live in plugins/EliteMobs/powers/, while NPC scripts live in plugins/EliteMobs/npc_scripts/.
NPC scripts run on the shared MagmaCore scripting runtime used by boss powers and FreeMinecraftModels props. They receive shared services such as context.world, context.zones, context.scheduler, context.cooldowns, and context.log, plus an NPC-specific context.npc table. Player and event data depend on the hook. Boss powers provide their own versions of several tables, so use the shared API signatures when writing NPC scripts.
NPC Lua scripts are still experimental. The NPC-specific hooks and the context.npc helpers may change. The shared tables (context.world, context.zones, context.scheduler, context.cooldowns, context.log, context.event, context.player) are the same ones documented in the Scripting Engine and Lua API Reference.
File Location
Create NPC script files in:
plugins/
EliteMobs/
npc_scripts/
wave.lua
Subfolders are scanned, recursively. However, scripts are registered by filename only, so npc_scripts/wave.lua and npc_scripts/town/wave.lua collide -- keep basenames unique across the whole tree.
Discovery selects one file for each basename, ignoring case, in stable absolute-path order and logs the selected and skipped paths when the copies differ. A broken selected file does not fall back to a skipped duplicate. Configuration references still use the selected file's exact case.
The .lua extension is optional in an NPC config: - wave and - wave.lua both resolve to wave.lua. If an NPC config references a script that does not exist, EliteMobs logs a warning and the NPC still spawns.
Attaching Scripts To NPCs
Add a scripts: list to the NPC config:
scripts:
- wave.lua
Multiple scripts can be attached to one NPC:
scripts:
- wave.lua
- greeting_particles.lua
Scripts run in priority order. Lower priority values run first. If priority is omitted, it defaults to 0. Equal priorities retain the order in the scripts: list. This orders NPC event dispatch, not independent scheduled tasks.
Script Shape
Every NPC script must return one table:
return {
api_version = 1,
priority = 0,
on_spawn = function(context)
context.state.spawned = true
context.npc:play_model_animation("idle")
end
}
Only these top-level fields are accepted:
| Field | Type | Notes |
|---|---|---|
api_version | number | Required. Must be 1. |
priority | number | Optional. Lower values run first. |
on_spawn | function | Runs after the NPC spawns. |
on_remove | function | Runs during normal NPC removal, before script shutdown and body removal. |
on_game_tick | function | Runs every server tick once this script's runtime has initialized and its clock is active. Keep this very light. |
on_npc_interact | function | Runs when the normal interaction handler reaches the script step; see the interaction limits below. |
on_npc_proximity_enter | function | Runs once when a player enters this NPC's activation radius. |
on_npc_proximity_leave | function | Runs once when a player leaves this NPC's activation radius. |
on_zone_enter | function | Runs when a player enters a zone this script is watching (see context.zones). |
on_zone_leave | function | Runs when a player leaves a watched zone. |
Zone watching only tracks players -- mobs and other entities never trigger on_zone_enter / on_zone_leave.
Unknown top-level keys are rejected during script loading. Helper functions should be declared as local functions above the returned table.
The loader evaluates file-scope code for validation, then each NPC script instance evaluates it again in its own sandbox. Do not use hook context during file-scope initialization. Each script has independent globals and context.state, including scripts attached to the same NPC.
NPC runtime initialization is lazy. The NPC host currently constructs each instance and dispatches on_spawn, but does not call the runtime's eager start() method. A file containing only on_game_tick therefore does not start ticking on its own. Define an on_spawn = function(context) end hook to initialize it at spawn, or let another declared event hook initialize it later.
A Lua error closes the affected script instance and cancels its owned work. Later NPC events discard that closed instance instead of recreating it. Reload the corrected definition and create a fresh NPC script instance. State is not persisted across removal or restart; on_remove is an NPC-removal hook, not a generic error or shutdown callback.
Proximity Hooks
NPC proximity hooks use the NPC's activationRadius config value.
| Hook | Fires when |
|---|---|
on_npc_proximity_enter | A player moves from outside the NPC's activation radius to inside it. |
on_npc_proximity_leave | A player moves from inside the NPC's activation radius to outside it. |
The server-side scanner checks each NPC/player pair every 100 ticks, normally five seconds at 20 TPS, using spherical distance from the current NPC body. A first scan with the player already inside counts as entry. A quick visit between scans can be missed. Standing near one NPC does not block another NPC's enter event, and remaining detected inside does not repeat it.
NPCs that are actively patrolling or being edited are skipped. A previously detected pair can therefore produce a leave event when movement starts and an enter event when detection resumes. Removal and player logout do not guarantee a leave hook: dispatch needs both the tracked NPC and an online player. An empty NPC or online-player set clears tracking without leave events. An activationRadius at or below zero disables proximity activation; a quest giver's separate 30-block marker range does not extend Lua activation.
The normal greeting, dialog, and quest indicator behavior still runs. The Lua hook adds behavior on top of it.
Shared Scripting Surface
Because NPC scripts run on the unified runtime, every NPC hook also receives the shared MagmaCore context tables used by FreeMinecraftModels scripts. EliteMobs boss powers run on the same runtime but use boss-specific variants for several tables. Full method lists live in the Lua API Reference and the Scripting Engine:
| Table | What it does |
|---|---|
context.world | World effects and queries: strike_lightning, spawn_particle, play_sound, set_block_at, place_temporary_block, spawn_entity, spawn_firework, get_nearby_entities, get_nearby_players, raycast, and more. Both coordinate (strike_lightning(x, y, z)) and location-table (strike_lightning_at_location(loc)) forms are accepted. |
context.zones | Create spatial zones with create_sphere(x, y, z, radius), create_cylinder(x, y, z, radius, height) or create_cuboid(x, y, z, xSize, ySize, zSize). Each returns a numeric handle, or nil without an owner world. watch(handle, on_enter, on_leave) enables the corresponding top-level hooks when a function argument is supplied; the supplied function itself is not called. unwatch(handle) removes the zone and stops tracking. |
context.scheduler | run_later(ticks, fn), run_repeating(delay, interval, fn), cancel(task_id). |
context.cooldowns | Shared MagmaCore cooldowns: local_ready, local_remaining, check_local, set_local, global_ready, set_global. |
context.log | info(msg), warn(msg), error(msg) — writes to the server console. |
context.event | Event or actor data when the hook supplies either. See below. |
context.player | The interacting/triggering player, when present. See below. |
context.state | A plain Lua table that persists for this NPC script instance until the NPC is removed. |
Shared zones use absolute coordinates in the NPC's world at creation; they do not follow a moving NPC. Creation immediately registers a per-tick scan of players in the owner's current world, even before watch installs enter/leave handling. Pass a function for each direction you want to enable and implement the matching on_zone_enter or on_zone_leave hook in the returned script table. These hooks receive context.player and an actor event table, but no zone identifier. unwatch removes the handle entirely; create another zone to watch again. Players already inside on the first scan count as entries, while an offline player cannot receive a leave callback.
Example: Smite On Interact
return {
api_version = 1,
on_npc_interact = function(context)
-- NPC scripts can now reach the full world API.
context.world:strike_lightning_at_location(context.npc:get_location())
end
}
context.npc
context.npc is available in every NPC hook.
Fields
| Field | Type | Notes |
|---|---|---|
name | string | NPC display name from the config. |
filename | string | NPC config filename. |
uuid | string | Runtime NPC UUID. |
activation_radius | number | Configured activation radius. |
current_location | location table | Snapshot location when the backing entity exists. |
entity_type | string | Bukkit entity type when the backing entity exists. |
Methods
| Method | Args | Returns | Notes |
|---|---|---|---|
is_valid() | - | boolean | Whether the NPC still has a valid backing entity. |
get_location() | - | location table | Current NPC location, or spawn location if the entity is not available. |
get_eye_location() | - | location table | Current eye location, or spawn location fallback. |
get_activation_radius() | - | number | Current configured activation radius. |
get_nearby_players(radius) | number | table | Player wrappers within spherical distance in the NPC's world. No game-mode filter; use a nonnegative radius. |
face_direction_or_location(target) | vector, location or entity table | nil | Faces a direction or same-world position. A table with world or current_location is treated as a position; bare coordinates are a direction. Does nothing while the patrol is actively moving. |
say_greeting(player?) | player, UUID, name, or nil | nil | Sends a configured greeting. Defaults to the triggering player when available. |
say_dialog(player?) | player, UUID, name, or nil | nil | Sends configured dialog. Defaults to the triggering player when available. |
say_farewell(player?) | player, UUID, name, or nil | nil | Sends configured farewell text. Defaults to the triggering player when available. |
play_model_animation(name) | string | nil | Plays a custom model animation if one exists. Safe no-op otherwise. |
patrol_pause() | - | boolean | Pauses this NPC's configured patrol. |
patrol_resume() | - | boolean | Clears a scripted hold or walk and resumes the configured patrol. |
walk_to(x, y, z) | three numbers | boolean | Walks to an offset from the authored spawn, then resumes patrol. Long routes are solved automatically. |
hold(x, y, z) | three numbers | boolean | Walks to an authored-origin offset and waits there. |
teleport(x, y, z) | three numbers | boolean | Teleports to an authored-origin offset when it is entity-ticking. |
remove() | - | nil | Removes the current NPC body through normal removal with reason OTHER; does not delete its authored placement. Do not call from on_remove, which would re-enter removal. |
Movement methods return false if the NPC has no configured patrol controller or the request cannot be accepted. See NPC & Boss Patrols for route configuration and long-distance behavior.
context.player
context.player is available in on_npc_interact, on_npc_proximity_enter, on_npc_proximity_leave, and watched-zone enter/leave hooks. It is nil in lifecycle hooks that do not involve a player.
It is the shared MagmaCore player wrapper also used by FMM scripts. It exposes health, potion effects, send_message, show_title, show_action_bar, get_held_item, raycasting, and more. Boss powers provide their own player wrapper, so their method list is not interchangeable with this one. See the Lua API Reference for the complete shared list. Commonly used here:
| Field / Method | Notes |
|---|---|
name | Player name. |
uuid | Player UUID. |
current_location | Player location read live on each field access. A previously saved location table remains a snapshot. |
get_eye_location() | Current player eye location. |
send_message(text) | Sends a chat message. Supports color codes. |
Always nil-check context.player before using it in shared helper functions.
entity_type is lowercase hereOn shared MagmaCore entity tables entity_type is the Bukkit name lowercased ("player", "zombie"). Only context.npc.entity_type and EliteMobs boss-power entity tables use the uppercase form. Compare case-insensitively if a script has to work with both.
EliteMobs fields added to every shared entity table
While EliteMobs is running it contributes extra fields to shared MagmaCore entity tables, including context.player, the wrappers returned by context.npc:get_nearby_players(...), and FreeMinecraftModels prop-script wrappers:
| Field / Method | Type | Notes |
|---|---|---|
is_elite | boolean | true if EliteMobs tracks the entity as an elite |
is_custom_boss | boolean | true if it is a Custom Boss (always false when is_elite is false) |
is_significant_boss | boolean | true for a Custom Boss whose health multiplier is above 1. This does not check whether it was summoned as a reinforcement. |
elite | table or nil | Present only on elites. See below |
elite_player | table or nil | On shared player wrappers built by EliteMobs, exposes tier when that player's elite inventory is loaded. Each field access builds a fresh table; a saved table keeps its earlier tier value. |
can_receive_hostile_effect(actor_uuid) | boolean | Checks a player actor against shared hostile-target rules and EliteMobs magic-weapon authorization. Pass a valid UUID string. |
The elite sub-table:
| Field / Method | Type | Notes |
|---|---|---|
elite.level | number | Elite level |
elite.name | string or nil | Elite display name |
elite.health | number | Current elite health (read live) |
elite.max_health | number | Maximum elite health (read live) |
elite.is_custom_boss | boolean | Same value as the top-level field |
elite.health_multiplier | number | Configured health multiplier |
elite.damage_multiplier | number | Configured damage multiplier |
elite:remove() | — | Despawns the elite |
-- Warn the approaching player if a real boss is loose near this NPC
on_npc_proximity_enter = function(context)
if context.player == nil then return end
local here = context.npc:get_location()
local nearby = context.world:get_nearby_entities(here.x, here.y, here.z, 40)
for i = 1, #nearby do
if nearby[i].is_significant_boss then
context.player:send_message("&cA boss is nearby: " .. tostring(nearby[i].elite.name))
return
end
end
end
EliteMobs boss-power wrappers use a separate builder and a different field set. Some names overlap, including is_elite, but the shared nested elite table is not interchangeable with boss-power methods. See Boss & Entities for that set. FMM supplies its own EliteMobs adapter; do not assume every extra field on an EliteMobs-built shared wrapper exists in FMM.
context.event
context.event is nil only when the hook has neither an event nor an actor. It is the shared MagmaCore event table:
| Field / Method | Notes |
|---|---|
is_cancelled | Cancellation snapshot when the table is created; false for non-cancellable events. It does not update after cancel() or uncancel(). |
cancel() | Present only when the underlying event is cancellable. Cancels that event. |
uncancel() | Present only when the underlying event is cancellable. Clears cancellation. |
player | The actor of the event (e.g. the interacting player), as a player wrapper, when present. |
For the interacting/proximity player, prefer context.player (it is set for those hooks).
on_npc_interact currently supplies the player without a Bukkit event. Its event table therefore has player and is_cancelled: false, but no cancel or uncancel methods. An NPC interaction script cannot cancel the normal NPC menu through this table.
The interaction path ignores already-cancelled Bukkit interactions, blocks name-tag use and NPCs being edited, and debounces interactions for one tick per player. A quest dialog objective that consumes the interaction returns before the Lua hook. Otherwise the hook runs before normal NPC menu/action dispatch. Proximity events are also non-cancellable; zone hooks supply an actor without a Bukkit event. These paths do not offer cancel() or uncancel() either.
State, Scheduler, And Cooldowns
context.state belongs to this individual NPC script instance and lasts until that instance ends. It is separate from the state of other scripts on the same NPC.
context.scheduler is the shared MagmaCore scheduler. Both the MagmaCore names and the EliteMobs run_after / run_every names work — they are aliases for the same behavior:
| Method | Args | Notes |
|---|---|---|
run_later(ticks, callback) / run_after(ticks, callback) | number, function | Runs once after a delay. Returns a task ID. |
run_repeating(delay, interval, callback) | number, number, function | Runs repeatedly after an initial delay. Returns a task ID. |
run_every(interval, callback) | number, function | Runs every interval ticks (initial delay 0). Returns a task ID. |
cancel(task_id) / cancel_task(task_id) | number | Cancels an owned task. |
Scheduler callbacks receive a fresh context. They do not receive the original context.player or context.event. All owned tasks are cancelled automatically when the NPC is removed.
context.cooldowns is the shared MagmaCore cooldown table:
| Method | Args | Returns | Notes |
|---|---|---|---|
local_ready(key?) | string | boolean | True when the local cooldown has expired. |
local_remaining(key?) | string | number | Ticks remaining, or 0 when ready. |
check_local(key?, duration) | string, number | boolean | If ready, starts the cooldown and returns true. |
set_local(duration, key?) | number, string | nil | Sets or resets the cooldown. |
global_ready() | - | boolean | True when the shared global cooldown is ready. |
set_global(duration) | number | nil | Starts the global cooldown. |
For check_local, the duration is always the second argument: use check_local(nil, 60) for the default key. set_local instead takes duration first. The default key includes the script filename. Explicit local keys and the global cooldown store are shared by scripts on the same NPC adapter, so use distinct keys unless coordination is intentional. Durations use elapsed monotonic time at 50 ms per nominal tick, not counted server ticks; nonpositive durations clear the cooldown. Setting a global cooldown does not automatically block hooks or check_local; check global_ready() yourself.
NPC scripts now use the shared MagmaCore cooldown order (check_local(key?, duration)), the same as boss powers and FreeMinecraftModels scripts. Earlier experimental NPC builds used check_local(duration, key?) — update any old scripts to the shared order.
Example: Wave On Approach
This script makes the NPC face a detected entering player and play the wave custom model animation. Proximity entry is based on the five-second scan, not every movement packet. The keyed cooldown limits replay when repeated entry notifications occur; it does not increase the scanner's frequency.
return {
api_version = 1,
priority = 0,
on_npc_proximity_enter = function(context)
if context.player == nil then return end
if context.cooldowns:check_local("wave:" .. context.player.uuid, 60) then
context.npc:face_direction_or_location(context.player.current_location)
context.npc:play_model_animation("wave")
end
end
}
play_model_animation(name) safely does nothing when the NPC has no custom model or the model does not have that animation.
Performance Guidelines
- Keep
on_game_tickhooks small. Once initialized, their clock runs every server tick, normally 20 times per second. A script without this hook can still incur per-tick zone scans or explicit scheduled work. - Prefer
on_npc_proximity_enterandon_npc_proximity_leavefor proximity behavior instead of polling nearby players every tick. - Use
context.cooldowns:check_local(...)to gate animations, sounds, and particle bursts. - Use
context.scheduler:run_repeating(...)with a reasonable interval when a behavior does not need to run every tick. - Avoid large searches in Lua.
context.npc:get_nearby_players(radius)is fine for small local checks, but broad scanning should stay in the plugin runtime.
Related Pages
- Creating NPCs -- NPC config fields, including
activationRadius - Lua Getting Started -- boss Lua powers
- Scripting Engine -- shared Lua concepts and the unified runtime
- Lua API Reference -- full method list for
context.world,context.player,context.zones, and more
