Lua Scripting: Examples & Patterns
This page contains FreeMinecraftModels prop script examples with walkthroughs. Supply the referenced model animations and content, then verify each example on your server before using it in a map.
If you are new to scripting, start with Getting Started. For full API details, see the Prop API.
Example: Invulnerable Prop
What this teaches: The simplest useful script -- cancel default left-click damage.
Save this script as invulnerable.lua in the scripts folder and reload before attaching it to a prop.
Full script file (click to expand)
return {
api_version = 1,
on_left_click = function(context)
if context.event then
context.event.cancel()
end
end
}
Walkthrough
-
Hook choice --
on_left_clickfires when a player punches (left-clicks) the prop. The Lua event is FMM's cancellation flag for its default prop damage handling. -
Event guard --
context.eventshould always be present in this hook, but the guard is good practice. -
Cancellation --
context.event.cancel()prevents FMM from applying the default damage for this click. It does not set general invulnerability against every damage source.
Usage
Add to your prop's .yml config:
isEnabled: true
scripts:
- invulnerable.lua
Example: Interactive Door
What this teaches: Toggle state on right-click, play and stop animations, and use context.state to track whether the door is open or closed.
Full script file (click to expand)
local OPEN_ANIMATION = "open"
local CLOSE_ANIMATION = "close"
return {
api_version = 1,
on_spawn = function(context)
context.state.is_open = false
end,
on_left_click = function(context)
-- Prevent default left-click damage
if context.event then
context.event.cancel()
end
end,
on_right_click = function(context)
local loc = context.prop.current_location
if context.state.is_open then
-- Close the door
context.prop:stop_animation()
context.prop:play_animation(CLOSE_ANIMATION, true, false)
context.state.is_open = false
if loc then
context.world:play_sound(
"BLOCK_WOODEN_DOOR_CLOSE",
loc.x, loc.y, loc.z,
1.0, 1.0
)
end
else
-- Open the door
context.prop:stop_animation()
context.prop:play_animation(OPEN_ANIMATION, true, false)
context.state.is_open = true
if loc then
context.world:play_sound(
"BLOCK_WOODEN_DOOR_OPEN",
loc.x, loc.y, loc.z,
1.0, 1.0
)
end
end
end
}
Walkthrough
-
Constants at file scope --
OPEN_ANIMATIONandCLOSE_ANIMATIONare defined above the return table. This makes them easy to change for different model files that might use different animation names. -
State initialization --
on_spawnsetscontext.state.is_open = false. State persists across hooks for this script binding on this prop. Other scripts on the same prop have separate state. -
Click protection -- The
on_left_clickhook cancels default damage from that click. -
Toggle logic --
on_right_clickrequests a stop and the appropriate animation, flips the flag, and plays a sound. The flag records the requested state, not confirmed playback.blend = truequeues the animation; stopping does not override the shared engine's active death animation. See Animations. This example animates the model; it does not change barrier collision or guarantee that an opening remains passable. -
Sounds accept Bukkit enum names, regardless of case, or valid sound keys such as
minecraft:block.note_block.harpand custom resource-pack keys. The client must have the sound in its resource pack.
Animation names like "open" and "close" must match the model file. A missing animation returns false; this example still changes its flag and plays its sound. Add result handling when those effects must depend on acceptance, and remember that true does not prove visible playback.
Example: Proximity Trigger with Particles
What this teaches: Zone creation, zone watching for enter/leave events, particle effects, and cleanup.
Full script file (click to expand)
local ZONE_RADIUS = 8
local PARTICLE_INTERVAL = 10 -- ticks between particle bursts
return {
api_version = 1,
on_spawn = function(context)
context.state.zone_handle = nil
context.state.particle_task = nil
context.state.players_in_zone = 0
local loc = context.prop.current_location
if loc == nil then return end
-- Create a sphere zone around the prop
local handle = context.zones:create_sphere(loc.x, loc.y, loc.z, ZONE_RADIUS)
context.state.zone_handle = handle
-- Pass placeholder functions to enable on_zone_enter and on_zone_leave.
-- The actual logic lives in the hooks below.
context.zones:watch(handle, function() end, function() end)
-- Start a repeating particle effect at the zone boundary
context.state.particle_task = context.scheduler:run_repeating(0, PARTICLE_INTERVAL, function(tick_context)
local prop_loc = tick_context.prop.current_location
if prop_loc == nil then return end
-- Spawn particles in a ring at the zone boundary
for angle = 0, 350, 30 do
local rad = math.rad(angle)
local px = prop_loc.x + math.cos(rad) * ZONE_RADIUS
local pz = prop_loc.z + math.sin(rad) * ZONE_RADIUS
if (tick_context.state.players_in_zone or 0) > 0 then
-- Flame particles when players are inside
tick_context.world:spawn_particle("FLAME", px, prop_loc.y + 0.5, pz, 1, 0, 0, 0, 0)
else
-- Green particles when zone is empty
tick_context.world:spawn_particle("HAPPY_VILLAGER", px, prop_loc.y + 0.5, pz, 1, 0, 0, 0, 0)
end
end
end)
end,
on_left_click = function(context)
-- Prevent default left-click damage
if context.event then
context.event.cancel()
end
end,
on_zone_enter = function(context)
context.state.players_in_zone = (context.state.players_in_zone or 0) + 1
end,
on_zone_leave = function(context)
context.state.players_in_zone = math.max(0, (context.state.players_in_zone or 0) - 1)
end,
on_destroy = function(context)
-- Clean up the repeating task
if context.state.particle_task then
context.scheduler:cancel(context.state.particle_task)
context.state.particle_task = nil
end
-- Clean up the zone watch
if context.state.zone_handle then
context.zones:unwatch(context.state.zone_handle)
context.state.zone_handle = nil
end
end
}
Walkthrough
-
Constants --
ZONE_RADIUSandPARTICLE_INTERVALare at file scope for easy tuning. -
State initialization --
on_spawnsets up all state fields tonil/0before doing anything else. -
Zone creation --
context.zones:create_sphere()creates a sphere at the prop's position at spawn. The numeric handle references that fixed zone. The particle ring reads the prop's current position, so use this example with a stationary prop or update the zone when moving it. -
Zone watching --
context.zones:watch(handle, function() end, function() end)enables theon_zone_enterandon_zone_leavehooks. Those hooks increment and decrement a counter stored incontext.state; they also receive the crossing player ascontext.player/context.event.playerif you want player-specific behavior. -
Particle loop -- A repeating task spawns particles in a ring every 10 server ticks (half a second at 20 TPS). The particle type changes based on whether players are in the zone.
-
Cleanup --
on_destroycancels the repeating task and unwatches the zone. While both are cleaned up automatically when the prop is removed, explicit cleanup is a best practice.
Spawning many particles every tick can impact performance. Use a reasonable interval (10-20 ticks) and keep particle counts low. The example above uses PARTICLE_INTERVAL = 10 (twice per second) with only 12 particles per ring.
Example: Sound-Emitting Prop
What this teaches: Playing sounds on interaction, using context.cooldowns, and preventing rapid-fire interactions.
Full script file (click to expand)
local SOUND_NAME = "BLOCK_NOTE_BLOCK_HARP"
local COOLDOWN_TICKS = 40 -- 2 seconds between sounds
return {
api_version = 1,
on_left_click = function(context)
-- Prevent default left-click damage
if context.event then
context.event.cancel()
end
end,
on_right_click = function(context)
-- Prevent spam
if not context.cooldowns:check_local("sound", COOLDOWN_TICKS) then
return
end
local loc = context.prop.current_location
if loc == nil then return end
-- Play the sound
context.world:play_sound(SOUND_NAME, loc.x, loc.y, loc.z, 1.0, 1.0)
-- Show some particles
context.world:spawn_particle("NOTE", loc.x, loc.y + 1.5, loc.z, 5, 0.3, 0.3, 0.3, 0)
end
}
Walkthrough
-
Cooldown pattern --
context.cooldowns:check_local("sound", COOLDOWN_TICKS)checks and immediately starts the cooldown if ready. It is shared by all players using this script binding on this prop. Cooldown ticks represent elapsed time at 50 ms each; scheduler delays count actual server ticks. -
Sounds accept Bukkit enum names, regardless of case, or valid sound keys such as
minecraft:block.note_block.harpand custom resource-pack keys. The client must have the sound in its resource pack. -
Particle feedback -- Note particles appear above the prop when the sound plays, giving a visual cue.
-
Click protection -- The
on_left_clickhook cancels default damage from that click.
Use context.cooldowns for normal cooldowns. Reserve context.state plus scheduler:run_later() for cases where you need custom state changes at the end of the delay.
Example: Animated Ambient Prop
What this teaches: Starting a looping animation on spawn, with a tick-based particle emitter.
Full script file (click to expand)
return {
api_version = 1,
on_spawn = function(context)
-- Start the idle animation immediately, looping
context.prop:play_animation("idle", false, true)
-- Emit ambient particles every 40 ticks (2 seconds)
context.state.ambient_task = context.scheduler:run_repeating(0, 40, function(tick_context)
local loc = tick_context.prop.current_location
if loc == nil then return end
tick_context.world:spawn_particle(
"ENCHANT",
loc.x, loc.y + 1, loc.z,
10, 0.5, 0.5, 0.5, 0.05
)
end)
end,
on_left_click = function(context)
if context.event then
context.event.cancel()
end
end,
on_destroy = function(context)
if context.state.ambient_task then
context.scheduler:cancel(context.state.ambient_task)
end
end
}
Walkthrough
-
Auto-start animation --
on_spawnimmediately plays a looping"idle"animation. Thefalsefor blend switches immediately. The built-inidlestate loops regardless of the supplied loop argument. -
Ambient particles -- A repeating task spawns enchantment table particles above the prop every 2 seconds, creating a magical ambient effect.
-
Cleanup --
on_destroycancels the particle task.
Example: Sittable Chair
What this teaches: Mounting a player on a prop with right-click, dismounting with left-click, and using context.event.player to get the interacting player.
Full script file (click to expand)
return {
api_version = 1,
on_right_click = function(context)
-- Handle seating here instead of also using the default mount fallback
if context.event then
context.event.cancel()
end
local player = context.event and context.event.player
if not player then return end
-- Check if the player is already sitting on this prop
local passengers = context.prop:get_passengers()
for i = 1, #passengers do
if passengers[i].uuid == player.uuid then
-- Player is already seated, do nothing on right-click
return
end
end
-- Mount the player on the chair
context.prop:mount(player)
local loc = context.prop.current_location
if loc then
context.world:play_sound("BLOCK_WOOD_PLACE", loc.x, loc.y, loc.z, 0.8, 1.2)
end
end,
on_left_click = function(context)
-- Cancel default left-click damage
if context.event then
context.event.cancel()
end
local player = context.event and context.event.player
if not player then return end
-- Check if the player is sitting and dismount them
local passengers = context.prop:get_passengers()
for i = 1, #passengers do
if passengers[i].uuid == player.uuid then
context.prop:dismount(player)
local loc = context.prop.current_location
if loc then
context.world:play_sound("BLOCK_WOOD_BREAK", loc.x, loc.y, loc.z, 0.8, 1.0)
end
return
end
end
end
}
Walkthrough
-
Right-click to sit --
on_right_clickgets the player fromcontext.event.player, checks whether they are already a passenger (to avoid double-mounting), and callscontext.prop:mount(player)to request the first availablem_mount-point seat. -
Left-click to stand --
on_left_clickcancels default prop damage for that click, then checks if the punching player is currently a passenger. If so,context.prop:dismount(player)ejects them. -
Passenger check --
context.prop:get_passengers()returns an array of entity tables. We compare UUIDs to find the interacting player in the list. -
Sound feedback -- Sounds accompany the queued mount and dismount requests, not confirmed seating changes. The right-click hook cancels FMM's default mount fallback so it does not also attempt to seat the player. The model must have a free
m_mount point.
Example: Blessing Shrine
What this teaches: Checking the player's held item, consuming items, applying random potion effects, cooldown management, and particle/sound feedback.
Full script file (click to expand)
local COOLDOWN_TICKS = 600 -- 30 seconds between uses
local BLESSINGS = {
{ effect = "speed", name = "Swiftness" },
{ effect = "strength", name = "Strength" },
{ effect = "regeneration", name = "Regeneration" },
{ effect = "resistance", name = "Resistance" },
{ effect = "jump_boost", name = "Leap" },
{ effect = "haste", name = "Haste" },
}
return {
api_version = 1,
on_spawn = function(context)
context.state.on_cooldown = false
end,
on_left_click = function(context)
-- Prevent default left-click damage
if context.event then
context.event.cancel()
end
end,
on_right_click = function(context)
local player = context.event and context.event.player
if not player then return end
-- Check cooldown
if context.state.on_cooldown then
player:send_message("&eThe shrine is recharging... Please wait.")
return
end
-- Check if the player is holding a gold ingot
local held = player:get_held_item()
if not held or held.type ~= "GOLD_INGOT" then
player:send_message("&eThe shrine demands a gold offering...")
return
end
-- Consume one gold ingot
player:consume_held_item(1)
-- Play blessing effects
local loc = context.prop.current_location
if loc then
context.world:spawn_particle("ENCHANT", loc.x, loc.y + 1.5, loc.z, 30, 0.5, 0.5, 0.5, 0.5)
context.world:spawn_particle("HAPPY_VILLAGER", loc.x, loc.y + 1, loc.z, 10, 0.3, 0.3, 0.3, 0)
context.world:play_sound("BLOCK_BEACON_ACTIVATE", loc.x, loc.y, loc.z, 1.0, 1.5)
end
-- Apply a random blessing
local chosen = BLESSINGS[math.random(#BLESSINGS)]
player:add_potion_effect(chosen.effect, 600, 1) -- 30 seconds, level II
player:send_message("&aThe shrine blesses you with " .. chosen.name .. "!")
-- Set cooldown
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function(later_context)
later_context.state.on_cooldown = false
end)
end
}
Walkthrough
-
Item check --
player:get_held_item()returns a table withtype,amount, anddisplay_namefor the main hand item (ornilif empty).held.typeis the uppercase Bukkit material name, so we compare it against"GOLD_INGOT". -
Item consumption --
player:consume_held_item(1)queues removal from the main hand. It reads that hand when the task runs and does not return a payment result. This demonstration is not an atomic exchange: a hand change can alter what is consumed, while the blessing is applied immediately. -
Random buff -- The
BLESSINGStable at file scope lists available positive effects.math.random(#BLESSINGS)picks one at random.player:add_potion_effect(effect, duration, amplifier)applies it --600ticks is 30 seconds, amplifier1is level II. -
Cooldown -- A flag shared by this script binding is reset after 600 server ticks (30 seconds at 20 TPS). All players using this shrine share it. For an elapsed-time cooldown, use
context.cooldownsas in the Sound-Emitting Prop example. -
Feedback -- Enchantment and happy-villager particles plus a beacon activation sound create a "divine blessing" feel.
Example: Cursed Shrine
What this teaches: Negative potion effects, lightning strikes, entity spawning, and branching logic based on player offerings.
Full script file (click to expand)
local COOLDOWN_TICKS = 600 -- 30 seconds between uses
local BUFFS = {
{ effect = "speed", name = "Swiftness" },
{ effect = "strength", name = "Strength" },
{ effect = "regeneration", name = "Regeneration" },
{ effect = "resistance", name = "Resistance" },
}
local CURSES = {
{ effect = "slowness", name = "Slowness" },
{ effect = "weakness", name = "Weakness" },
{ effect = "poison", name = "Poison" },
{ effect = "mining_fatigue", name = "Mining Fatigue" },
}
local ZOMBIE_COUNT = 4
return {
api_version = 1,
on_spawn = function(context)
context.state.on_cooldown = false
end,
on_left_click = function(context)
-- Prevent default left-click damage
if context.event then
context.event.cancel()
end
end,
on_right_click = function(context)
local player = context.event and context.event.player
if not player then return end
-- Check cooldown
if context.state.on_cooldown then
player:send_message("&7The dark shrine pulses with residual energy...")
return
end
local loc = context.prop.current_location
if loc == nil then return end
-- Check if the player is holding a gold ingot
local held = player:get_held_item()
if not held or held.type ~= "GOLD_INGOT" then
-- No offering -- punish the player!
player:send_message("&4The shrine demands tribute! You dare approach empty-handed?!")
-- Strike lightning on the player
local player_loc = player.current_location
if player_loc then
context.world:strike_lightning(player_loc.x, player_loc.y, player_loc.z)
end
-- Spawn a horde of zombies around the shrine
for i = 1, ZOMBIE_COUNT do
local angle = math.rad((360 / ZOMBIE_COUNT) * i)
local spawn_x = loc.x + math.cos(angle) * 3
local spawn_z = loc.z + math.sin(angle) * 3
context.world:spawn_entity("zombie", spawn_x, loc.y, spawn_z)
end
-- Apply a random curse
local chosen_curse = CURSES[math.random(#CURSES)]
player:add_potion_effect(chosen_curse.effect, 400, 1) -- 20 seconds, level II
player:send_message("&cThe shrine curses you with " .. chosen_curse.name .. "!")
-- Ominous effects
context.world:spawn_particle("SMOKE", loc.x, loc.y + 1, loc.z, 30, 0.5, 0.5, 0.5, 0.05)
context.world:play_sound("ENTITY_WITHER_AMBIENT", loc.x, loc.y, loc.z, 1.0, 0.5)
else
-- Gold offered -- reward the player
player:consume_held_item(1)
-- Apply a random buff
local chosen_buff = BUFFS[math.random(#BUFFS)]
player:add_potion_effect(chosen_buff.effect, 600, 1) -- 30 seconds, level II
player:send_message("&aThe dark shrine accepts your offering. You are blessed with " .. chosen_buff.name .. "!")
-- Positive feedback
context.world:spawn_particle("ENCHANT", loc.x, loc.y + 1.5, loc.z, 30, 0.5, 0.5, 0.5, 0.5)
context.world:play_sound("BLOCK_BEACON_ACTIVATE", loc.x, loc.y, loc.z, 1.0, 0.8)
end
-- Set cooldown regardless of path
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function(later_context)
later_context.state.on_cooldown = false
end)
end
}
Walkthrough
-
Branching on offering -- The script checks
player:get_held_item()and takes one of two paths: punishment if the player has no gold, or reward if they do. -
Lightning strike --
context.world:strike_lightning(x, y, z)strikes real (damaging) lightning at the player's location. The player's position is read fromplayer.current_location. -
Zombie spawning --
context.world:spawn_entity("zombie", x, y, z)spawns ordinary zombies in a circle around the shrine. Removing the prop does not remove those mobs. The example does not check terrain or limit the total number still alive. -
Negative potion effects --
player:add_potion_effect("poison", 400, 1)applies 20 seconds of Poison II. Effect names are lowercase strings matching Bukkit'sPotionEffectTypenames. -
Reward path -- The shrine queues main-hand consumption and applies a random positive effect. As with the Blessing Shrine, this is a demonstration, not an atomic payment and reward exchange.
-
Cooldown -- Both branches set the same flag for all players using this script binding. It resets after 600 server ticks (30 seconds at 20 TPS).
Spawning multiple entities at once can impact server performance. Keep the count low (4-6) and consider adding a per-player cooldown if many players use the shrine simultaneously.
Example: Spinning Globe
What this teaches: Playing a timed animation on interaction, scheduling an animation stop, and mechanical sound effects.
Full script file (click to expand)
local SPIN_ANIMATION = "spin"
local SPIN_DURATION = 100 -- 5 seconds in ticks
return {
api_version = 1,
on_spawn = function(context)
context.state.is_spinning = false
end,
on_left_click = function(context)
-- Prevent default left-click damage
if context.event then
context.event.cancel()
end
end,
on_right_click = function(context)
-- Prevent starting a new spin while already spinning
if context.state.is_spinning then
return
end
local loc = context.prop.current_location
if loc == nil then return end
-- Start the spin animation (non-looping)
context.prop:play_animation(SPIN_ANIMATION, true, false)
context.state.is_spinning = true
-- Play a mechanical clicking sound
context.world:play_sound("BLOCK_CHAIN_PLACE", loc.x, loc.y, loc.z, 1.0, 1.5)
-- Schedule the animation to stop after 5 seconds
context.scheduler:run_later(SPIN_DURATION, function(later_context)
later_context.prop:stop_animation()
later_context.state.is_spinning = false
local stop_loc = later_context.prop.current_location
if stop_loc then
later_context.world:play_sound("BLOCK_CHAIN_FALL", stop_loc.x, stop_loc.y, stop_loc.z, 1.0, 0.8)
end
end)
end
}
Walkthrough
-
State guard --
context.state.is_spinningprevents multiple overlapping spin requests. The flag is set when the spin starts and cleared when the scheduled stop fires. -
Timed animation --
play_animation(SPIN_ANIMATION, true, false)requests a non-looping animation through the queue. The stop request runs 100 server ticks after the click, not after playback begins. A busy animation queue can shorten or prevent the visible spin, and the shared engine does not stop an active death animation. The example sets its flag even if the animation request returnsfalse. -
Mechanical sounds --
BLOCK_CHAIN_PLACEgives a clicking/mechanical start sound;BLOCK_CHAIN_FALLgives a winding-down stop sound. Adjust pitch to taste. -
Callback context -- The
run_latercallback useslater_context(notcontext) for all state and world access. This is the correct pattern for scheduler callbacks.
The "spin" animation name must match what is defined in your model file. If your model uses a different name (e.g. "rotate", "turn"), update the SPIN_ANIMATION constant accordingly.
Example: Jumpscare Prop
What this teaches: Proximity zone triggers, one-shot scare effects with a long cooldown, and combining sound/particles/animation for dramatic impact.
Full script file (click to expand)
local SCARE_RADIUS = 3
local COOLDOWN_TICKS = 1200 -- 60 seconds between scares
local SCARE_ANIMATION = "jumpscare"
return {
api_version = 1,
on_spawn = function(context)
context.state.on_cooldown = false
context.state.zone_handle = nil
local loc = context.prop.current_location
if loc == nil then return end
-- Create a small sphere zone around the prop
local handle = context.zones:create_sphere(loc.x, loc.y, loc.z, SCARE_RADIUS)
context.state.zone_handle = handle
-- Watch for players entering the zone. The actual scare logic
-- lives in on_zone_enter below.
context.zones:watch(handle, function() end, nil)
end,
on_zone_enter = function(context)
if context.state.on_cooldown then
return
end
local scare_loc = context.prop.current_location
if scare_loc == nil then return end
-- Play the jumpscare animation
context.prop:stop_animation()
context.prop:play_animation(SCARE_ANIMATION, false, false)
-- Scary sound
context.world:play_sound(
"ENTITY_GHAST_SCREAM",
scare_loc.x, scare_loc.y, scare_loc.z,
1.0, 0.7
)
-- Burst of smoke particles
context.world:spawn_particle(
"CAMPFIRE_SIGNAL_SMOKE",
scare_loc.x, scare_loc.y + 1, scare_loc.z,
20, 0.5, 0.5, 0.5, 0.05
)
-- Set cooldown so it does not trigger again immediately
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function(later_context)
later_context.state.on_cooldown = false
end)
end,
on_left_click = function(context)
-- Prevent default left-click damage
if context.event then
context.event.cancel()
end
end,
on_destroy = function(context)
-- Clean up the zone watch
if context.state.zone_handle then
context.zones:unwatch(context.state.zone_handle)
context.state.zone_handle = nil
end
end
}
Walkthrough
-
Proximity zone --
context.zones:create_sphere()creates a fixed 3-block radius zone at the spawn position.context.zones:watch(handle, function() end, nil)enableson_zone_enter. Move or recreate the zone if you move the prop. -
Scare effects -- The
on_zone_enterhook plays a"jumpscare"animation, a ghast scream sound, and spawns campfire smoke particles. The combination creates a sudden, startling effect. -
Cooldown -- All players share this script binding's flag. It resets after 1200 server ticks (60 seconds at 20 TPS), then the next zone entry can trigger another scare.
-
No leave hook -- The
nilthird argument tocontext.zones:watch()means we do not care when players leave the zone. -
Cleanup --
on_destroyunwatches the zone. While zones are cleaned up automatically when the prop is removed, explicit cleanup is a best practice.
For best jumpscare effect, hide the prop around a corner or in a dark area. The 3-block radius ensures the player is close before the scare triggers. Adjust SCARE_RADIUS and COOLDOWN_TICKS to taste.
Example: Goblin Spawner Prop
What this teaches: Using prop:spawn_elitemobs_boss() to spawn a custom boss from a prop interaction, with a graceful fallback if EliteMobs is not installed.
Full script file (click to expand)
local BOSS_FILE = "goblin_warrior.yml"
local COOLDOWN_TICKS = 200 -- 10 seconds between spawns
return {
api_version = 1,
on_spawn = function(context)
context.state.on_cooldown = false
end,
on_left_click = function(context)
-- Prevent default left-click damage
if context.event then
context.event.cancel()
end
end,
on_right_click = function(context)
local player = context.event and context.event.player
if not player then return end
-- Prevent spawn spam
if context.state.on_cooldown then
player:send_message("&7The spawner is recharging...")
return
end
local loc = context.prop.current_location
if loc == nil then return end
-- Try to spawn the EliteMobs boss
local boss = context.prop:spawn_elitemobs_boss(BOSS_FILE, loc.x, loc.y + 1, loc.z)
if boss then
-- Success -- play spawn effects
context.world:spawn_particle("FLAME", loc.x, loc.y + 1, loc.z, 20, 0.5, 0.5, 0.5, 0.05)
context.world:play_sound("ENTITY_EVOKER_PREPARE_SUMMON", loc.x, loc.y, loc.z, 1.0, 1.0)
player:send_message("&cA goblin warrior emerges!")
else
-- EliteMobs is not installed or the boss file was not found
context.log:warn("Could not spawn boss '" .. BOSS_FILE .. "' -- is EliteMobs installed?")
player:send_message("&7The spawner fizzles... (EliteMobs not available)")
-- Fizzle particles as visual feedback
context.world:spawn_particle("SMOKE", loc.x, loc.y + 1, loc.z, 10, 0.3, 0.3, 0.3, 0.02)
context.world:play_sound("BLOCK_FIRE_EXTINGUISH", loc.x, loc.y, loc.z, 0.8, 1.2)
end
-- Set cooldown
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function(later_context)
later_context.state.on_cooldown = false
end)
end
}
Walkthrough
-
Boss spawning --
context.prop:spawn_elitemobs_boss(filename, x, y, z)spawns an EliteMobs custom boss at the given coordinates. The filename must match a.ymlfile in EliteMobs'custombossesfolder. -
Failure feedback --
spawn_elitemobs_boss()returnsnilwhen the integration cannot return a living boss, including an unavailable plugin, missing configuration, or failed spawn. The example's message suggests checking EliteMobs; it does not identify the exact cause. -
Spawn offset -- The requested position is one block above the prop. This is not a terrain or collision check; choose a clear spawn area appropriate for the boss.
-
Cooldown -- The script binding allows one attempt every 200 server ticks (10 seconds at 20 TPS), shared by all players. It has no live-boss cap and does not remove spawned bosses when the prop disappears. Add encounter limits when using it for repeatable gameplay.
-
Visual/audio distinction -- Success uses flame particles and an evoker summoning sound for a dramatic spawn. Failure uses smoke and fire-extinguish for a clear "fizzle" effect, so the player knows something went wrong without checking the console.
The boss filename (e.g. "goblin_warrior.yml") must correspond to an existing custom boss configuration in EliteMobs. If you are distributing a map or dungeon that uses this script, include the boss configuration file and document the EliteMobs dependency.
Item effects
The equipped-item script examples have been retired. Use custom item definitions and authored enchantments for current item effects.
Best Practices
-
Start with a tiny hook and verify. Write a single
on_spawnthat sends a log message. Confirm it fires. Then build from there. -
Keep helper functions local. Declare helpers like
local function toggle_door(context)above the return table. This keeps them out of global scope. -
Initialize all state in
on_spawn. If you readcontext.state.is_openinon_right_clickbut never set it inon_spawn, it will beniland your comparisons may behave unexpectedly. -
Cancel repeating tasks when done. Every
run_repeatingshould have a matchingcancelinon_destroy. Leaked tasks waste CPU. -
Use fresh scheduler callback contexts. Scheduler callbacks receive a fresh context parameter. Always use that parameter inside the callback, not the outer
context. -
Keep
on_game_ticklightweight. It runs every server tick (20 times per second at 20 TPS). Gate expensive work behindcontext.cooldownsor a scheduler interval. -
Control left-click damage deliberately. Include
on_left_clickcancellation when punching should not trigger default prop damage; it does not block every damage source. -
Particle names accept either
FLAMEorflame; the API normalizes case before looking up the Bukkit particle. Sounds accept Bukkit enum names, regardless of case, or valid sound keys such asminecraft:block.note_block.harpand custom resource-pack keys. The client must have the sound in its resource pack.
Common Beginner Mistakes
-
Using the outer
contextinside a scheduler callback. The outer context captures a snapshot at the time the hook ran. Inside callbacks, always use the callback's own parameter. -
Forgetting to cancel repeating tasks. If you start a
run_repeatinginon_spawnbut never cancel it, the task runs until the prop is removed. -
Not initializing state in
on_spawn. Readingcontext.state.xbefore setting it returnsnil, which may break your logic silently. -
Wrong animation names. If
play_animation("open")returnsfalse, the animation name does not match what is in the model file. Check the model for exact names. -
Particle names accept either
FLAMEorflame; the API normalizes case before looking up the Bukkit particle. Sounds accept Bukkit enum names, regardless of case, or valid sound keys such asminecraft:block.note_block.harpand custom resource-pack keys. The client must have the sound in its resource pack. -
Forgetting
api_version = 1. The returned table must include this field, or FMM will not load the script. -
Putting helpers inside the returned table. Apart from
api_versionand optionalpriority, the table accepts only supported hook names with function values. Define helpers locally outside that table. FMM runs attached scripts in their configured list order;prioritydoes not reorder them.
QC Checklist
Use this checklist to verify a prop script before deploying it:
- The file returns exactly one table with
api_version = 1. - Every hook name matches an entry in the prop hook list exactly. Enchantment hooks belong to a separate runtime.
- Check both
context.eventandcontext.event.cancelbefore cancelling outside a known click hook. context.statefields are initialized inon_spawn.- Every
scheduler:run_repeating(...)call has a matchingscheduler:cancel(...)inon_destroy. - Scheduler callbacks use the callback's own context parameter, not the outer
context. on_game_tickhooks gate expensive work behind a check.- All method names exist in the Prop API reference -- no invented aliases.
- Particle names accept either
FLAMEorflame; the API normalizes case before looking up the Bukkit particle. Sounds accept Bukkit enum names, regardless of case, or valid sound keys such asminecraft:block.note_block.harpand custom resource-pack keys. The client must have the sound in its resource pack. - The script does not call any blocking or long-running operations inside a hook or callback.
AI Generation Tips
If you want AI to generate prop scripts reliably, make sure the prompt includes:
- Exact hook name -- e.g.,
on_right_click, not "when the player clicks the prop". - Animation names from the model file -- the AI cannot guess these; provide them.
- Sound enum names -- e.g.,
"BLOCK_NOTE_BLOCK_HARP", not "harp sound". - Particle enum names -- e.g.,
"FLAME", not "fire particles". - Whether left-clicks should deal default damage -- cancel that handling with
on_left_clickandcontext.event.cancel(). - Use verified method names -- prefer the Prop API reference. If a required operation is not listed, check the current runtime source rather than inventing an alias.
Good prompt example
Write a FMM prop script that plays the "activate" animation when right-clicked, cancels default left-click damage, spawns FLAME particles at the prop location on click, plays the BLOCK_LEVER_CLICK sound, and has a 2-second cooldown between clicks using context.cooldowns:check_local("activate", 40).
Item content compatibility
Older item packs using scripts: require updated item definitions and enchantment content. FMM skips those retired definitions and leaves the files unchanged. Adding a material: field no longer activates an item script.