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 now run on the same unified MagmaCore scripting runtime as boss powers, FreeMinecraftModels props, and FMM items. That means an NPC script gets the full shared scripting surface — context.world (including strike_lightning), context.zones, context.scheduler, context.cooldowns, context.log, context.event, and context.playerplus an NPC-specific context.npc table. Anything MagmaCore exposes to scripts is available here too.

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.

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.


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 when the NPC is removed.
on_game_tickfunctionRuns every server tick while the NPC is valid. Keep this very light.
on_npc_interactfunctionRuns when a player interacts with the NPC.
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.


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.

These hooks are tracked per NPC and per player by the server-side proximity scanner. Standing near one NPC does not block another NPC from firing its own enter event, and staying inside the radius does not spam repeated enter events.

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 (create_sphere(x, y, z, radius), create_cylinder(x, y, z, radius, height), create_cuboid(x, y, z, xSize, ySize, zSize)) -- each returns a numeric handle. watch(handle, on_enter, on_leave) starts tracking (the callbacks fire your on_zone_enter / on_zone_leave hooks, not the functions you pass); unwatch(handle) stops it.
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.eventThe current Bukkit event, when one is present. 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.

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 radius of the NPC.
face_direction_or_location(target)vector or locationnilFaces a direction vector or turns toward a location/player location.
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.

context.player

context.player is available in on_npc_interact, on_npc_proximity_enter, and on_npc_proximity_leave. It is nil in lifecycle hooks that do not involve a player.

It is the shared MagmaCore player wrapper — the same full living-entity/player table boss powers and FMM scripts use, so it exposes far more than the basics (health, potion effects, send_message, show_title, show_action_bar, get_held_item, raycasting, and more). See the Lua API Reference for the complete list. Commonly used here:

Field / MethodNotes
namePlayer name.
uuidPlayer UUID.
get_location()Current player location.
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 every MagmaCore entity table -- context.player, the wrappers returned by context.npc:get_nearby_players(...), and the ones FreeMinecraftModels prop and item scripts see:

FieldTypeNotes
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 -- the practical "this is a real boss, not a reinforcement" check
elitetable or nilPresent only on elites. See below

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

These fields do not appear on EliteMobs boss-power entity wrappers, which are built by a separate boss-side table builder -- see Boss & Entities for that set.


context.event

context.event is nil when the hook has no Bukkit event. When present it is the shared MagmaCore event table:

Field / MethodNotes
is_cancelledWhether the underlying event is cancelled (only meaningful for cancellable events).
cancel()Cancels the event, when it is cancellable.
uncancel()Un-cancels the event, when it is cancellable.
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).


State, Scheduler, And Cooldowns

context.state is a plain Lua table that persists for this NPC script instance until the NPC is removed.

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.
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 the entering player and play the wave custom model animation. Proximity enter already fires once per NPC/player pair while the player remains inside the radius; the cooldown keeps quick leave/re-enter cycles from replaying the animation too often.

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:get_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. They run 20 times per second for every NPC script instance that defines them. (Scripts that don't declare on_game_tick are never ticked.)
  • 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.