Lua Scripting: Prop API
This page covers every API available to FreeMinecraftModels prop scripts: context.prop, context.event, context.player, context.projectile, context.world, context.zones, context.scheduler, context.state, context.cooldowns, and context.log. If you are new to scripting, start with Getting Started first.
context.prop
The prop table provides information about the prop entity and methods to control its animations. FMM caches this table per prop for performance; fields such as current_location are lazy/live, so reads still reflect the current prop state.
Fields
| Field | Type | Notes |
|---|---|---|
prop.model_id | string | The blueprint model name (e.g. "torch_01") |
prop.current_location | location table | The prop's current position. This is a live/lazy field, not a one-time snapshot. |
The location table has the standard fields: x, y, z, world, yaw, pitch.
Example: reading prop info
return {
api_version = 1,
on_spawn = function(context)
context.log:info("Prop spawned: " .. (context.prop.model_id or "unknown"))
local loc = context.prop.current_location
if loc then
context.log:info("Location: " .. loc.x .. ", " .. loc.y .. ", " .. loc.z)
end
end
}
prop:play_animation(name, blend, loop)
Plays a named animation on the prop model.
| Parameter | Type | Default | Notes |
|---|---|---|---|
name | string | required | The animation name as defined in the model file |
blend | boolean | true | Queues a switch after the current state tick when true; interrupts immediately when false. Does not cross-fade. |
loop | boolean | true | Whether a custom animation loops; built-in states use their own policy |
Returns true when the request is accepted, including queued or suppressed playback; false when it cannot be accepted. A terminal death state rejects later playback. An attack request during a custom animation is accepted without interrupting it. The queue has one slot, so a later queued request replaces an earlier one. Accepted playback does not prove a visible frame; see Animations.
Example
return {
api_version = 1,
on_right_click = function(context)
local success = context.prop:play_animation("open", true, false)
if not success then
context.log:warn("Animation 'open' not found on this model!")
end
end
}
prop:stop_animation()
Clears queued playback and returns to idle when available, otherwise stops the current shared state. Terminal death remains active in the shared engine, although this call also sends the Bedrock backend a stop request.
Takes no parameters.
Example
return {
api_version = 1,
on_right_click = function(context)
context.prop:stop_animation()
end
}
prop:hurt_visual()
Plays the visual hurt animation (red tint flash) on the prop without dealing any actual damage to it.
Takes no parameters.
Example
return {
api_version = 1,
on_left_click = function(context)
-- Flash red when punched, but don't actually take damage
if context.event then
context.event.cancel()
end
context.prop:hurt_visual()
end
}
prop:pickup()
Removes the prop from the world and drops a placement paper item at its location. The dropped item can be right-clicked on a block to place the prop again.
Takes no parameters and returns nothing. The remove/drop work is queued onto the Bukkit main thread. This method does not check player permissions or preserve the prop's stored inventory/book data in the placement item. Decide access in the calling hook and handle stored contents before pickup. Do not queue repeated pickups for the same prop.
Example
return {
api_version = 1,
on_right_click = function(context)
-- Let players pick up the prop by right-clicking it
context.prop:pickup()
end
}
prop:mount(player)
Mounts a player onto the first available mount point seat on the prop. The model must have mount-point bones defined.
| Parameter | Type | Notes |
|---|---|---|
player | entity table | A player entity table (e.g. from context.player or context.event.player) |
Returns true when the player was found, the prop has mount points, and the mount action was queued. Returns false when those basic references are not valid. A true return does not prove a seat was ultimately available when the queued action ran.
When a right-click script handles seating, cancel context.event to prevent FMM's default mount fallback from also trying to seat the player.
Example
return {
api_version = 1,
on_right_click = function(context)
if context.event then context.event.cancel() end
local player = context.event and context.event.player
if player then
context.prop:mount(player)
end
end
}
prop:dismount(player)
Dismounts a player from their mount point seat on the prop.
| Parameter | Type | Notes |
|---|---|---|
player | entity table | A player entity table |
Returns true when the player was found, the prop has a mount manager, and the dismount check was queued. Returns false when those basic references are not valid.
Example
return {
api_version = 1,
on_right_click = function(context)
if context.event then context.event.cancel() end
local player = context.event and context.event.player
if player then
-- Toggle mount/dismount
local passengers = context.prop:get_passengers()
for i = 1, #passengers do
if passengers[i].uuid == player.uuid then
context.prop:dismount(player)
return
end
end
context.prop:mount(player)
end
end
}
prop:get_passengers()
Returns a Lua array of entity tables for all current passengers on the prop.
Takes no parameters.
Example
return {
api_version = 1,
on_game_tick = function(context)
local passengers = context.prop:get_passengers()
if #passengers > 0 then
context.log:info("Prop has " .. #passengers .. " passenger(s)")
end
end
}
prop:has_mount_points()
Returns whether this prop has mount-point bones defined in its model.
Takes no parameters. Returns true or false.
Example
return {
api_version = 1,
on_right_click = function(context)
if context.event then context.event.cancel() end
local player = context.event and context.event.player
if player and context.prop:has_mount_points() then
context.prop:mount(player)
end
end
}
prop:spawn_elitemobs_boss(filename, x, y, z)
Spawns an EliteMobs custom boss at the given location. Requires EliteMobs to be installed on the server.
| Parameter | Type | Notes |
|---|---|---|
filename | string | The custom boss filename (e.g. "my_boss.yml") |
x | number | X coordinate |
y | number | Y coordinate |
z | number | Z coordinate |
Returns a living entity table for the spawned boss, or nil when the integration cannot return a living boss, including an unavailable plugin, missing configuration, or failed spawn. A nil result does not identify the cause.
Example
return {
api_version = 1,
on_right_click = function(context)
local loc = context.prop.current_location
if loc then
local boss = context.prop:spawn_elitemobs_boss("dungeon_guardian.yml", loc.x, loc.y + 1, loc.z)
if boss then
context.log:info("Spawned boss: " .. (boss.name or "unknown"))
else
context.log:warn("Could not spawn boss -- is EliteMobs installed?")
end
end
end
}
prop:open_inventory(player, title, rows)
Queues a chest inventory GUI for the player. Each prop has one shared inventory: the first open builds it from the prop's saved PersistentDataContainer contents, and later opens show that same live inventory to every viewer. FMM saves it back to the prop when the last viewer closes it, when the prop's chunk unloads, and before drop_inventory() reads it.
| Parameter | Type | Default | Notes |
|---|---|---|---|
player | entity table | required | The player to show the inventory to |
title | string | required | The inventory title (supports & color codes) |
rows | int | 3 | Number of rows (1-6, where 6 = 54 slots = double chest) |
Returns true if the prop/player references were valid and the open action was queued, false otherwise. Rows are clamped to the 1-6 range before the inventory is created.
The size and title come from the call that builds the inventory; while it is open, later calls reuse it as is. When the saved contents hold more slots than the requested rows, the inventory grows to fit them, so a smaller rows value never discards stored items. If the saved data cannot be read, the inventory does not open. The action is skipped when the prop was removed before it ran. The return value does not guarantee that the inventory opened.
prop:is_viewing_inventory(player)
Returns whether the inventory listener currently tracks this player as a viewer of this prop's inventory.
| Parameter | Type | Notes |
|---|---|---|
player | entity table | The player to check |
Returns true or false.
Example: Close animation when inventory is closed
context.state["task_" .. player.uuid] = context.scheduler:run_repeating(5, 5, function(tick_context)
if not tick_context.prop:is_viewing_inventory(player) then
tick_context.prop:play_animation("close", true, false)
tick_context.scheduler:cancel(tick_context.state["task_" .. player.uuid])
end
end)
prop:place_book(player)
Takes the written or writable book from the player's main hand and stores it on the prop.
| Parameter | Type | Notes |
|---|---|---|
player | entity table | The player holding the book |
Returns true if the prop/player references were valid and the action was queued. The later main-thread action only places a book if the player is holding a writable or written book and the prop does not already have one.
prop:read_book(player)
Opens the stored book for reading by the player.
| Parameter | Type | Notes |
|---|---|---|
player | entity table | The player to show the book to |
Returns true if the prop/player references were valid and the read action was queued. The later action requires a stored, deserializable WRITTEN_BOOK; a stored writable book is not opened for reading by this method.
prop:take_book(player)
Returns the stored book to the player's inventory and removes it from the prop.
| Parameter | Type | Notes |
|---|---|---|
player | entity table | The player to give the book to |
Returns true if the prop/player references were valid and the take action was queued. It does not guarantee that a stored book existed.
Leave inventory space: the stored entry is removed before adding the book, and overflow is not dropped. place_book serializes the held stack before reducing its amount by one; use ordinary single books.
prop:has_book()
Checks whether the backing armor stand has the stored-book PDC key. It does not validate the serialized book. Takes no parameters.
prop:drop_inventory()
Queues saving the open inventory, if any, and closing it for every viewer, then reads the saved contents, clears its PDC entry and drops the items at the prop. Edits made in the open inventory up to that moment are included. Failed decoding can leave no items to drop after the stored entry is cleared.
Takes no parameters. Returns true if the prop has a valid backing armor stand and the drop action was queued. It does not guarantee that stored items existed.
prop:drop_book()
Queues dropping the stored book at the prop's location. The action clears the stored entry before decoding the book; a decoding failure can therefore leave no book to drop.
Takes no parameters. Returns true if the prop has a valid backing armor stand and the drop action was queued. It does not guarantee that a stored book existed.
prop:set_persistent_data(key, value)
Stores a string value on the prop's armor stand PersistentDataContainer. It persists with the backing armor stand across normal world saves and chunk unloads; permanent prop removal or replacement with a placement item does not carry it over.
| Parameter | Type | Notes |
|---|---|---|
key | string | A unique Bukkit key suffix using lowercase letters, digits, ., _, - or / (stored under fmm_lua_<key> internally); invalid keys raise an error |
value | string | The value to store. Use tostring() for numbers and booleans. |
Returns true if successful, false if the prop has no backing armor stand.
prop:get_persistent_data(key)
Retrieves a string value previously stored with set_persistent_data. Returns nil if the key has not been set.
| Parameter | Type | Notes |
|---|---|---|
key | string | The key name used in set_persistent_data |
Example: Persistent toggle state
return {
api_version = 1,
on_spawn = function(context)
local saved = context.prop:get_persistent_data("active")
context.state.active = saved == "true"
end,
on_right_click = function(context)
context.state.active = not context.state.active
context.prop:set_persistent_data("active", tostring(context.state.active))
end
}
Item effects
The equipped-item context.item API has been retired. Define effects with authored enchantments and attach them through the item's namespaced enchantments: list.
context.event
Event data for the current hook. Available in prop click, projectile-hit and generic zone hooks. Returns nil in hooks that have no associated event or player actor (on_spawn, on_game_tick, on_destroy).
FMM clicks use a plugin cancellation flag for default prop handling. The Lua wrapper exposes only the fields and methods listed here.
Fields and Methods
| Field or Method | Type | Notes |
|---|---|---|
event.player | player entity table | The player who triggered the event or crossed a watched generic zone boundary. Available in prop on_left_click, on_right_click, on_zone_enter, and on_zone_leave. In on_projectile_hit it is the living entity that shot the projectile, which can be a mob, and it is absent when the shooter is not a living entity. See Player Entity Methods for all fields and methods. |
event.is_cancelled | boolean | Cancellation state when the context was built. This field is not refreshed after calling cancel() or uncancel(). |
event.cancel() | function | Cancels the event (e.g. prevents damage or interaction) |
event.uncancel() | function | Un-cancels a previously cancelled event |
cancel and uncancel ignore whatever is passed to them, so context.event.cancel() and context.event:cancel() behave identically. This page uses the dot form; either form works.
Generic zone hooks supply a player actor without a cancellation flag, so event.cancel() and event.uncancel() are absent and event.is_cancelled is false. Prop click hooks supply FMM's cancellation flag; cancelling it prevents the matching default prop action.
Treat event.is_cancelled as an initial-state snapshot. If your own script calls event.cancel() or event.uncancel(), keep your own local flag if you need to remember that change later in the same hook.
The current FMM event table does not expose Bukkit-specific fields such as target, block, projectile, or item. Use context.player, context.event.player, context.projectile (in on_projectile_hit), player:get_target_entity(range), context.world:raycast(...), or nearby-entity queries when you need extra context.
on_projectile_hit
Fires when a projectile hits the prop's hitbox, once per projectile.
| Context value | Notes |
|---|---|
context.projectile | Entity table for the projectile (arrow, trident, snowball and so on). Only set in this hook |
context.event.player | The living entity that shot the projectile, if any |
context.player | The shooter when it is a player, otherwise nil |
The hook runs after FMM has handled the hit, and FMM's default projectile handling does nothing for props. Calling context.event.cancel() here does not stop a non-piercing projectile from being used up by the hit. The hook does not fire when another plugin already cancelled the hit.
return {
api_version = 1,
on_projectile_hit = function(context)
local shooter = context.player
if shooter then
shooter:send_message("&eYou hit the target!")
end
context.prop:play_animation("hit", false, false)
end
}
Example: Cancelling left-click damage
Example
return {
api_version = 1,
on_left_click = function(context)
if context.event then
context.event.cancel()
end
end
}
Example: Checking cancellation state
Example
return {
api_version = 1,
on_left_click = function(context)
if context.event and not context.event.is_cancelled then
context.event.cancel()
context.log:info("Damage cancelled!")
end
end
}
Inside scheduled callbacks (scheduler:run_later, scheduler:run_repeating), context.event is always nil. Event modification can only happen during the event hook itself.
context.world
This is the FreeMinecraftModels/MagmaCore world API. See context.world for the full reference.
All methods documented on the global page (get_block_at, set_block_at, spawn_particle, play_sound, strike_lightning, get_time, set_time, get_nearby_entities, get_nearby_players, spawn_entity, get_highest_block_y, raycast, place_temporary_block, drop_item, spawn_firework) are available in FMM. See the MagmaCore world API for full details on world:raycast() (cast a ray and detect hit entities/blocks), world:place_temporary_block() (temporary block replacement), and world:spawn_firework() (spawn firework rockets with custom colors and shapes). EliteMobs boss powers start from the same world base and add boss-specific location-table methods for spawning bosses, reinforcements, falling blocks, temporary blocks, and more; see EliteMobs World & Environment.
FMM-Specific World Additions
FreeMinecraftModels layers three optional EliteMobs loot helpers onto context.world. They are always present, but each returns false and does nothing when EliteMobs is not installed:
| Method | Notes |
|---|---|
world:drop_elitemobs_procedural_loot(player, level, location?) | Generates an EliteMobs item for the player. Respects EliteMobs' direct-inventory setting; overflow is dropped at the player, otherwise delivery uses the selected location. Returns false when procedural item drops are disabled |
world:drop_elitemobs_random_loot(player, level, location?) | Rolls the EliteMobs loot tables for the player at the given level |
world:drop_elitemobs_custom_loot(player, file, level, location?) | Drops a specific EliteMobs custom item file for the player. Returns false when the file does not resolve |
Full signatures live in the Lua API Reference. The prop-side equivalent for bosses is prop:spawn_elitemobs_boss(...), documented above.
Player Entity Methods
Player entity tables are returned from context.player, context.event.player, and context.world:get_nearby_players(). Generic MagmaCore on_zone_enter / on_zone_leave hooks set context.player and context.event.player to the entering or leaving player.
The entity tables, living entity methods, player-specific methods, and Player UI methods documented on the global page are the MagmaCore tables used by FMM. EliteMobs boss powers expose similar but boss-specific entity tables documented in Boss & Entities. See the MagmaCore Lua Scripting Engine for the full FMM reference covering entity base fields, living entity fields and methods, player-specific fields and methods, and Player UI Methods. New player methods include player:get_target_entity() (raycast targeting), player:get_eye_location(), player:get_look_direction(), player:send_block_change() (per-player fake blocks), and player:reset_block() -- see Player-Specific Methods for details.
FMM-Specific Entity Fields
Every entity table built inside an FMM script gets these extra fields automatically (via FMM's LuaEntityEnricher):
| Field | Type | Notes |
|---|---|---|
entity.is_modeled | boolean | true if this Bukkit entity is the underlying entity of a ModeledEntity |
entity.is_prop | boolean | true if this entity is an armor stand backing a PropEntity |
entity.model | table or nil | Populated only when is_modeled = true (see below) |
These FMM flags and the model reference are captured when the entity table is built, not refreshed on every read. Obtain a new table after a model attachment changes. Player tables also expose has_permission(node) and an is_op snapshot; has_permission checks the player's current permission.
When entity.model is present, it exposes:
| Field / Method | Notes |
|---|---|
model.model_id | The blueprint model name (e.g. "dragon") |
model.is_dynamic | true if this is a DynamicEntity (attached to a living entity) |
model:play_animation(name, blend, loop) | Requests a named animation. blend and loop default to false on this bridge. true means accepted, not necessarily rendered |
model:stop_animations() | Clears queued playback and returns to idle when available; does not interrupt terminal death |
model:remove() | Immediately removes the modeled entity and all its bones |
on_right_click = function(context)
local player = context.event and context.event.player
if not player then return end
local target = player:get_target_entity(8)
if target and target.is_modeled then
target.model:play_animation("hurt", true, false)
end
end
EliteMobs Entity Fields
When EliteMobs is installed, FMM forwards to EliteMobs' enricher so the same entity tables also expose:
| Field | Type | Notes |
|---|---|---|
entity.is_elite | boolean | true if the entity is tracked by EliteMobs |
entity.is_custom_boss | boolean | true if it is a custom boss configuration |
entity.is_significant_boss | boolean | true for custom bosses with a healthMultiplier > 1 (filters out trash named mobs) |
entity.elite | table or nil | Populated only when is_elite = true. Contains level, name, health, max_health, health_multiplier, damage_multiplier, is_custom_boss, plus elite:remove() |
The flags and most elite fields are snapshots; health and max_health are lazy reads. With EliteMobs enabled, entity:can_receive_hostile_effect(player_uuid) also applies EliteMobs' magic-target authorization after the shared hostile-target check. An unavailable adapter denies the check rather than treating allies as standalone targets.
context.zones
This is the FreeMinecraftModels/MagmaCore zones API. See context.zones for the full reference. EliteMobs boss powers use a different context.zones table with native EliteMobs zone definitions; see EliteMobs Zones & Targeting.
context.scheduler
The scheduler API documented here is the MagmaCore scheduler used by FreeMinecraftModels scripts and EliteMobs NPC scripts. The EliteMobs-style names (run_after, run_every, and cancel_task) are aliases on the shared scheduler, so either naming style works. Boss powers expose the same aliases through their boss-specific context. See context.scheduler for the full FMM reference.
context.state
The state API is shared by FreeMinecraftModels scripts, EliteMobs boss powers, and EliteMobs NPC scripts. See context.state for the full reference.
context.log
The logging API documented here is the FreeMinecraftModels/MagmaCore logger (info, warn, error). EliteMobs NPC scripts use the same logger; EliteMobs boss powers expose info, warn, and debug. See context.log for the full reference.
context.cooldowns
The cooldown API documented here is the shared MagmaCore/FMM order used by FreeMinecraftModels scripts and EliteMobs NPC scripts: check_local(key?, duration). EliteMobs boss powers use the same argument order with boss-specific backing stores. See context.cooldowns for the full reference.
| Method | Notes |
|---|---|
local_ready(key?) | Checks whether a local cooldown is ready. |
local_remaining(key?) | Returns remaining local cooldown ticks, or 0. |
check_local(key?, duration) | Checks and starts a local cooldown atomically. |
set_local(duration, key?) | Sets a local cooldown without checking. |
global_ready() | Checks the script owner's shared global cooldown. |
set_global(duration) | Sets the script owner's shared global cooldown. |
Use context.cooldowns:check_local("my_key", 40) for normal prop action cooldowns.
Runtime Model
One Runtime Per Script Instance
Every entry in a prop's scripts: list gets an independent Lua runtime. FMM discovers and validates source when registering the provider, then evaluates the cached source in a fresh sandbox for each binding. Editing or adding a Lua file requires a successful reload before new bindings use it. Script callbacks run in configured list order; parsed priority does not reorder prop bindings. When the prop is removed, its script instances are shut down.
Item enchantments have a separate lifecycle; they do not create an equipped-item prop-script instance.
This means:
- Local variables declared at file scope are private to that script instance.
context.stateis completely isolated between instances, even if they share the same script file.
Scheduled Task Ownership
All tasks created through context.scheduler are owned by the runtime that created them. When a prop is removed:
- The runtime shuts down.
- Every owned task -- both one-shot and repeating -- is automatically cancelled.
- All zone watches are cleared.
on_destroy runs first only when the prop is removed for good: picked up, broken, deleted with /fmm deleteall, or removed by a plugin. When the prop's chunk or world unloads, or FMM reloads or shuts down, the runtime stops without calling on_destroy. The prop comes back with a new runtime, and a new on_spawn, when its chunk loads again.
Cooldown Scoping
The shared scripting engine exposes local cooldown helpers (local_ready, local_remaining, check_local, set_local) and global cooldown helpers (global_ready, set_global). FMM scopes those stores as follows:
| Script type | Local store scope | Global store scope |
|---|---|---|
| Prop script | Per ScriptInstance (prop + script file) | Per PropEntity (shared across every script bound to that prop) |
Prop global cooldown stores are cleared when the prop is unregistered or the script manager shuts down during a successful reload or disable. A rejected reload preparation retains the current runtime. Cooldown durations use monotonic elapsed time at 50 ms per requested tick, while scheduler delays follow server ticks; they can differ under server lag.
Execution Budget
Every hook invocation, every scheduled callback and the initial evaluation of the script file itself run under a hard execution budget. The budget is enforced inside the Lua VM, so it applies while your code is still running rather than only checking the clock afterwards.
| Limit | Value |
|---|---|
| Current-thread CPU time | 50 milliseconds |
| Lua instructions executed | 250,000 |
Whichever limit is hit first aborts the call with a Lua error and disables the script instance. The messages are:
Lua instruction budget exceeded (250000 instruction limit)
Lua CPU-time budget exceeded (50ms current-thread CPU limit)
The instruction counter bounds a pure Lua loop such as while true do end. Time is checked every 1,024 Lua instructions and when the call returns; the watchdog does not preempt a blocking Java/Bukkit method while that method is running.
The time half of the budget is measured as current-thread CPU time, not wall clock, so a script is not charged for time when the server thread was descheduled. On a JVM where current-thread CPU timing is unavailable, MagmaCore falls back to a deliberately more generous 250 millisecond elapsed-time limit (Lua elapsed-time fallback budget exceeded (250ms fallback; current-thread CPU time unavailable)) while keeping the same 250,000-instruction ceiling, so non-terminating scripts stay bounded either way.
Nested calls share one budget: if a hook invokes a callback that invokes another, the whole chain is measured as a single 50ms-of-CPU / 250,000-instruction allowance, not one allowance each.
Evaluation during provider discovery rejects an invalid definition before registration. The cached source is evaluated again for each new instance, under the same budget.
To stay within budget:
- Avoid unbounded loops inside hooks.
- Keep
on_game_tickhandlers lightweight -- they run every single tick. - Use
context.scheduler:run_repeating(...)to spread work across ticks.
Complete Hook Reference
This table lists the active prop hooks.
The context.event column describes FMM's cancellation flag or zone actor. The Lua wrapper exposes event.player, event.is_cancelled, event.cancel(), and event.uncancel() where applicable.
Active Prop Hooks (8)
| Hook | Fires when | context.event |
|---|---|---|
on_spawn | Prop spawns into the world, or loads again with its chunk | nil |
on_game_tick | Every server tick (50ms at 20 TPS) | nil |
on_destroy | Prop is removed for good (not on chunk unload or reload) | nil |
on_left_click | Player left-clicks the prop | default-damage cancellation flag |
on_right_click | Player right-clicks the prop | default-interaction cancellation flag |
on_zone_enter | Player enters a watched zone | zone player actor (context.player / context.event.player; not cancellable) |
on_zone_leave | Player leaves a watched zone | zone player actor (context.player / context.event.player; not cancellable) |
on_projectile_hit | A projectile hits the prop | projectile-hit event; event.player is the shooter. See on_projectile_hit |
Item effects
Use authored enchantments for held-item effects. The old 22-hook item script table is no longer a runtime contract.
Next Steps
- Examples & Patterns -- prop scripts with walkthroughs and setup notes
- Troubleshooting -- common issues, debugging tips, and a QC checklist
- Getting Started -- file structure, hooks, first script walkthrough
EliteMobs transport
context.world:start_elitemobs_transport(player, route_id) starts a saved EliteMobs route for a player in the context's world. It returns whether the request was accepted and requires EliteMobs with that route loaded. The ID has no .yml extension. See transport routes.