Skip to main content

Lua Scripting: NPC Scripts

webapp_banner.jpg

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.

Experimental Feature

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:

FieldTypeNotes
api_versionnumberRequired. Must be 1.
prioritynumberOptional. Lower values run first.
on_spawnfunctionRuns after the NPC spawns.
on_removefunctionRuns during normal NPC removal, before script shutdown and body removal.
on_game_tickfunctionRuns every server tick once this script's runtime has initialized and its clock is active. Keep this very light.
on_npc_interactfunctionRuns when the normal interaction handler reaches the script step; see the interaction limits below.
on_npc_proximity_enterfunctionRuns once when a player enters this NPC's activation radius.
on_npc_proximity_leavefunctionRuns once when a player leaves this NPC's activation radius.
on_zone_enterfunctionRuns when a player enters a zone this script is watching (see context.zones).
on_zone_leavefunctionRuns 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.

HookFires when
on_npc_proximity_enterA player moves from outside the NPC's activation radius to inside it.
on_npc_proximity_leaveA 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:

TableWhat it does
context.worldWorld 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.zonesCreate 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.schedulerrun_later(ticks, fn), run_repeating(delay, interval, fn), cancel(task_id).
context.cooldownsShared MagmaCore cooldowns: local_ready, local_remaining, check_local, set_local, global_ready, set_global.
context.loginfo(msg), warn(msg), error(msg) — writes to the server console.
context.eventEvent or actor data when the hook supplies either. See below.
context.playerThe interacting/triggering player, when present. See below.
context.stateA 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​

FieldTypeNotes
namestringNPC display name from the config.
filenamestringNPC config filename.
uuidstringRuntime NPC UUID.
activation_radiusnumberConfigured activation radius.
current_locationlocation tableSnapshot location when the backing entity exists.
entity_typestringBukkit entity type when the backing entity exists.

Methods​

MethodArgsReturnsNotes
is_valid()-booleanWhether the NPC still has a valid backing entity.
get_location()-location tableCurrent NPC location, or spawn location if the entity is not available.
get_eye_location()-location tableCurrent eye location, or spawn location fallback.
get_activation_radius()-numberCurrent configured activation radius.
get_nearby_players(radius)numbertablePlayer 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 tablenilFaces 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 nilnilSends a configured greeting. Defaults to the triggering player when available.
say_dialog(player?)player, UUID, name, or nilnilSends configured dialog. Defaults to the triggering player when available.
say_farewell(player?)player, UUID, name, or nilnilSends configured farewell text. Defaults to the triggering player when available.
play_model_animation(name)stringnilPlays a custom model animation if one exists. Safe no-op otherwise.
patrol_pause()-booleanPauses this NPC's configured patrol.
patrol_resume()-booleanClears a scripted hold or walk and resumes the configured patrol.
walk_to(x, y, z)three numbersbooleanWalks to an offset from the authored spawn, then resumes patrol. Long routes are solved automatically.
hold(x, y, z)three numbersbooleanWalks to an authored-origin offset and waits there.
teleport(x, y, z)three numbersbooleanTeleports to an authored-origin offset when it is entity-ticking.
remove()-nilRemoves 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 / MethodNotes
namePlayer name.
uuidPlayer UUID.
current_locationPlayer 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 here

On 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 / MethodTypeNotes
is_elitebooleantrue if EliteMobs tracks the entity as an elite
is_custom_bossbooleantrue if it is a Custom Boss (always false when is_elite is false)
is_significant_bossbooleantrue for a Custom Boss whose health multiplier is above 1. This does not check whether it was summoned as a reinforcement.
elitetable or nilPresent only on elites. See below
elite_playertable or nilOn 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)booleanChecks a player actor against shared hostile-target rules and EliteMobs magic-weapon authorization. Pass a valid UUID string.

The elite sub-table:

Field / MethodTypeNotes
elite.levelnumberElite level
elite.namestring or nilElite display name
elite.healthnumberCurrent elite health (read live)
elite.max_healthnumberMaximum elite health (read live)
elite.is_custom_bossbooleanSame value as the top-level field
elite.health_multipliernumberConfigured health multiplier
elite.damage_multipliernumberConfigured 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 / MethodNotes
is_cancelledCancellation 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.
playerThe 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:

MethodArgsNotes
run_later(ticks, callback) / run_after(ticks, callback)number, functionRuns once after a delay. Returns a task ID.
run_repeating(delay, interval, callback)number, number, functionRuns repeatedly after an initial delay. Returns a task ID.
run_every(interval, callback)number, functionRuns every interval ticks (initial delay 0). Returns a task ID.
cancel(task_id) / cancel_task(task_id)numberCancels 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:

MethodArgsReturnsNotes
local_ready(key?)stringbooleanTrue when the local cooldown has expired.
local_remaining(key?)stringnumberTicks remaining, or 0 when ready.
check_local(key?, duration)string, numberbooleanIf ready, starts the cooldown and returns true.
set_local(duration, key?)number, stringnilSets or resets the cooldown.
global_ready()-booleanTrue when the shared global cooldown is ready.
set_global(duration)numbernilStarts 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.

Unified cooldown API

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_tick hooks 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_enter and on_npc_proximity_leave for 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.