Lua Scripting: Examples & Patterns
This page contains full working examples of FreeMinecraftModels prop scripts, plus practical patterns and best practices. Each example includes a walkthrough explaining what it does and why.
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 damage so the prop cannot be broken.
This is the premade script that ships with FreeMinecraftModels.
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. Under the hood, this is anEntityDamageByEntityEventon the prop's backing armor stand. -
Event guard --
context.eventshould always be present in this hook, but the guard is good practice. -
Cancellation --
context.event.cancel()cancels the damage event, which prevents the armor stand from taking damage and being destroyed.
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)
-- Make the door invulnerable
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 all hooks for this prop instance. -
Invulnerability -- The
on_left_clickhook cancels damage so the door cannot be accidentally broken. -
Toggle logic --
on_right_clickcheckscontext.state.is_open, stops any current animation, plays the appropriate animation, flips the state, and plays a sound. Thestop_animation()call beforeplay_animation()ensures clean transitions. -
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 what is defined in the model file. If the animation is not found, play_animation() returns false and nothing happens. Check your model file for the exact animation names.
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)
-- Make invulnerable
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 zone centered on the prop. The returned handle is a numeric ID used to reference this zone later. -
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 around the prop every half second. 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)
-- Make invulnerable
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 whether the"sound"cooldown is ready and, if it is, immediately starts it. This keeps the click handler small and avoids creating scheduler tasks just to reset a flag. -
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.
-
Invulnerability -- The
on_left_clickhook cancels damage as usual.
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 means it starts fresh without blending from a previous animation. Thetruefor loop means it repeats indefinitely. -
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)
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 damage so the chair is invulnerable
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 seat them on the prop's armor stand. -
Left-click to stand --
on_left_clickcancels the damage event (invulnerability), 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 -- A wood-place sound plays when sitting down and a wood-break sound when standing up, giving tactile feedback.
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)
-- Make invulnerable
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)removes one item from the player's main hand stack. -
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 -- The same boolean-flag-plus-scheduler pattern from the Sound-Emitting Prop example. A 30-second cooldown prevents shrine spam.
-
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)
-- Make invulnerable
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 vanilla zombies. The loop distributes them evenly in a circle around the shrine using trigonometry. -
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 -- When gold is offered, the shrine consumes one ingot and applies a random positive effect, mirroring the Blessing Shrine behavior.
-
Cooldown -- A 30-second cooldown applies regardless of which branch was taken, preventing rapid-fire punishment or reward.
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)
-- Make invulnerable
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)plays the animation once (no loop). Thescheduler:run_later(100, ...)call stops the animation after exactly 5 seconds, in case the animation itself is longer or looping. -
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)
-- Make invulnerable
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 3-block radius zone.context.zones:watch(handle, function() end, nil)enableson_zone_enter, which fires when any player steps inside. -
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. -
60-second cooldown -- The boolean flag pattern prevents the scare from triggering repeatedly. Once triggered, the prop goes silent for 60 seconds (
COOLDOWN_TICKS = 1200), then rearms. -
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)
-- Make invulnerable
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. -
Graceful fallback --
spawn_elitemobs_boss()returnsnilif EliteMobs is not installed or the boss file does not exist. The script handles this with a warning log message, a fizzle particle effect, and a player message explaining the failure. -
Spawn offset -- The boss is spawned at
loc.y + 1(one block above the prop) to prevent the boss from clipping into the prop or the ground. -
Cooldown -- A 10-second cooldown prevents players from flooding the area with goblin warriors. Adjust
COOLDOWN_TICKSbased on your gameplay needs. -
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. If you define this hook, it runs every server tick (20 times per second). Gate expensive work behindcontext.cooldownsor a scheduler interval. -
Make props invulnerable by default. Unless you want the prop to be breakable, include the
on_left_clickdamage cancellation in every script. -
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 functions inside the returned table that are not hooks. Helper functions must be declared above the
returnstatement. Only hook names (on_spawn,on_right_click, etc.) are allowed as keys in the returned table.
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 or item hook list exactly.
context.eventis guarded withif context.event thenbefore callingcancel().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 the prop should be invulnerable -- if yes, include
on_left_clickwithcontext.event.cancel(). - Only use documented method names -- if it is not on the Prop API page, it does not exist.
Good prompt example
Write a FMM prop script that plays the "activate" animation when right-clicked, makes the prop invulnerable, 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.