Lua Scripting: Examples & Patterns
This page contains full working examples of FreeMinecraftModels prop and item 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 & Item 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. -
Sound feedback --
context.world:play_sound()plays the Bukkit Sound enum name at the prop's location. Sound names must be UPPER_CASE enum names.
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
-- Watch for enter/leave
context.zones:watch(
handle,
function(player)
-- on_enter
context.state.players_in_zone = (context.state.players_in_zone or 0) + 1
end,
function(player)
-- on_leave
context.state.players_in_zone = math.max(0, (context.state.players_in_zone or 0) - 1)
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
-- Red particles when players are inside
tick_context.world:spawn_particle("DUST", 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_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()registers callbacks for player enter and leave. The callbacks increment and decrement a counter stored incontext.state. -
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, cooldown-like behavior using state and scheduler, 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_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)
-- Prevent spam
if context.state.on_cooldown 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)
-- 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
-
Cooldown pattern -- Since FMM prop scripts do not have a built-in
context.cooldownsAPI like EliteMobs, the example implements a simple cooldown usingcontext.state.on_cooldownandscheduler:run_later(). The flag is set totruewhen the sound plays, and a delayed task resets it afterCOOLDOWN_TICKS. -
Sound playback --
context.world:play_sound()takes the Bukkit Sound enum name in UPPER_CASE, coordinates, volume, and pitch. -
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. -
Scheduler callback context -- The
run_latercallback receiveslater_context, a fresh context. We uselater_context.state(notcontext.state) to reset the cooldown flag. Since state is shared, both point to the same table -- but using the callback's context parameter is the correct habit.
FMM prop scripts do not have EliteMobs' context.cooldowns API. Use the pattern shown here: a boolean flag in context.state combined with scheduler:run_later() to reset it. This gives you full control over cooldown duration and behavior.
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). We compareheld.typeagainst"gold_ingot"(lowercase material name). -
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
context.zones:watch(
handle,
function(player)
-- on_enter: trigger the scare
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,
nil -- no on_leave callback needed
)
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()registers an on-enter callback that fires when any player steps inside. -
Scare effects -- The on-enter callback 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 callback -- The
nilsecond 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.
Example: Frost Shockwave Sword (Item Script)
What this teaches: A complete item script demonstrating on_equip state initialization, on_shift_right_click for triggering an ability, scheduler:run_repeating() for an expanding wave, get_nearby_entities() for target finding, entity combat methods, particle/sound effects, terrain modification with get_highest_block_y(), show_action_bar() for UI feedback, and guarding against non-living entities.
Full script file (click to expand)
-- Frost Shockwave Sword
-- Shift + Right-click to release a horizontal frost wave
local WAVE_MAX_RADIUS = 12
local WAVE_SPEED = 2
local WAVE_TICK_INTERVAL = 2
local WAVE_DAMAGE = 4.0
local WAVE_KNOCKBACK = 1.2
local WAVE_CONE_ANGLE = 70
return {
api_version = 1,
on_equip = function(context)
context.state.wave_active = false
end,
on_shift_right_click = function(context)
if context.state.wave_active then return end
local loc = context.player.current_location
local yaw_rad = math.rad(loc.yaw)
local dir_x = -math.sin(yaw_rad)
local dir_z = math.cos(yaw_rad)
context.event:cancel()
context.state.wave_active = true
context.state.hit_entities = {}
context.state.destroyed_blocks = {}
local current_radius = 1
local origin_x, origin_y, origin_z = loc.x, loc.y, loc.z
context.player:show_action_bar("&b&lFrost Shockwave!", 40)
context.world:play_sound("ENTITY_PLAYER_ATTACK_SWEEP", origin_x, origin_y, origin_z, 1.5, 0.5)
local task_id = context.scheduler:run_repeating(0, WAVE_TICK_INTERVAL, function()
if current_radius > WAVE_MAX_RADIUS then
context.state.wave_active = false
context.scheduler:cancel(context.state.wave_task)
return
end
local half_angle = math.rad(WAVE_CONE_ANGLE / 2)
local steps = math.floor(current_radius * 8)
for i = 0, steps do
local angle = (i / steps) * 2 * math.pi
local px = origin_x + math.cos(angle) * current_radius
local pz = origin_z + math.sin(angle) * current_radius
local to_x, to_z = px - origin_x, pz - origin_z
local to_len = math.sqrt(to_x * to_x + to_z * to_z)
if to_len > 0 then
local dot = (to_x * dir_x + to_z * dir_z) / to_len
if dot >= math.cos(half_angle) then
local ground_y = context.world:get_highest_block_y(math.floor(px), math.floor(pz))
context.world:spawn_particle("SNOWFLAKE", px, ground_y + 1.0, pz, 3, 0.3, 0.2, 0.3, 0.02)
local bx, bz = math.floor(px), math.floor(pz)
local block_key = bx .. "," .. bz
if not context.state.destroyed_blocks[block_key] then
context.state.destroyed_blocks[block_key] = true
local block_type = context.world:get_block_at(bx, ground_y, bz)
if block_type ~= "bedrock" and block_type ~= "obsidian" and block_type ~= "barrier" then
context.world:set_block_at(bx, ground_y, bz, "AIR")
end
end
end
end
end
local entities = context.world:get_nearby_entities(origin_x, origin_y, origin_z, current_radius + 1)
for _, entity in ipairs(entities) do
if entity.uuid ~= context.player.uuid and not context.state.hit_entities[entity.uuid] then
local eloc = entity.current_location
local dx, dz = eloc.x - origin_x, eloc.z - origin_z
local dist = math.sqrt(dx * dx + dz * dz)
if dist >= current_radius - 2 and dist <= current_radius + 1 and dist > 0 then
local dot = (dx * dir_x + dz * dir_z) / dist
if dot >= math.cos(half_angle) and entity.damage then
context.state.hit_entities[entity.uuid] = true
entity:damage(WAVE_DAMAGE)
entity:push((dx/dist)*WAVE_KNOCKBACK, 0.3, (dz/dist)*WAVE_KNOCKBACK)
if entity.is_alive then entity:add_potion_effect("SLOWNESS", 60, 1) end
context.world:spawn_particle("SNOWFLAKE", eloc.x, eloc.y+1, eloc.z, 10, 0.5, 0.5, 0.5, 0.1)
end
end
end
end
context.world:play_sound("BLOCK_GLASS_BREAK",
origin_x + dir_x * current_radius, origin_y,
origin_z + dir_z * current_radius, 0.8, 1.5)
current_radius = current_radius + WAVE_SPEED
end)
context.state.wave_task = task_id
end
}
Walkthrough
-
State initialization --
on_equipsetswave_active = falseto prevent multiple overlapping shockwaves. This hook fires when the player equips the sword. -
Directional cone -- The player's yaw is converted to a direction vector (
dir_x,dir_z). A 70-degree cone in front of the player determines the wave's spread. -
Expanding wave --
scheduler:run_repeating()runs every 2 ticks. Each tick, the wave radius grows byWAVE_SPEED. Particles are spawned in a ring, but only within the cone angle. -
Terrain destruction --
get_highest_block_y()finds the ground level at each point. Surface blocks are destroyed (set to AIR), with bedrock/obsidian/barrier excluded. Adestroyed_blockstable prevents double-processing. -
Entity targeting --
get_nearby_entities()finds all entities near the origin. The script checks that each entity is within the current wave band, inside the cone, and has not already been hit. Theif entity.damage thenguard is critical --get_nearby_entities()returns ALL entities (armor stands, items, etc.), not just living ones. -
Combat effects -- Hit entities take damage, get knocked back in the wave's direction, and receive 3 seconds of Slowness II.
-
Player feedback --
show_action_bar()displays "Frost Shockwave!" for 40 ticks. Glass break sounds advance with the wave front. -
Cleanup -- When the radius exceeds
WAVE_MAX_RADIUS, the repeating task cancels itself and resets thewave_activeflag.
Always check if entity.damage then before calling entity:damage(), entity:push(), or entity:add_potion_effect(). The get_nearby_entities() method returns all entities in range, including non-living ones like armor stands, dropped items, and experience orbs that do not have these methods.
Example: Healing Wand (Item Script)
What this teaches: A simpler item script showing right-click activation, cooldown management, the item:consume() method for limited uses, and healing with potion effects.
Full script file (click to expand)
-- Healing Wand
-- Right-click to heal yourself. Consumes one charge per use.
local HEAL_AMOUNT = 6.0 -- 3 hearts
local COOLDOWN_TICKS = 60 -- 3 seconds
local REGEN_DURATION = 40 -- 2 seconds of regen
local REGEN_AMPLIFIER = 1 -- Regeneration II
return {
api_version = 1,
on_equip = function(context)
context.state.on_cooldown = false
end,
on_right_click = function(context)
-- Check cooldown
if context.state.on_cooldown then
context.player:show_action_bar("&cWand recharging...", 20)
return
end
-- Check remaining charges
local uses = context.item:get_uses()
if uses <= 0 then
context.player:show_action_bar("&c&lOut of charges!", 40)
context.world:play_sound("BLOCK_FIRE_EXTINGUISH",
context.player.current_location.x,
context.player.current_location.y,
context.player.current_location.z,
0.8, 1.5)
return
end
-- Cancel the event so we don't interact with blocks
if context.event then
context.event:cancel()
end
-- Consume one charge
context.item:set_uses(uses - 1)
-- Heal the player
local loc = context.player.current_location
context.player:add_potion_effect("REGENERATION", REGEN_DURATION, REGEN_AMPLIFIER)
-- Visual and audio feedback
context.world:spawn_particle("HEART", loc.x, loc.y + 1.5, loc.z, 8, 0.4, 0.3, 0.4, 0)
context.world:spawn_particle("HAPPY_VILLAGER", loc.x, loc.y + 1, loc.z, 15, 0.5, 0.5, 0.5, 0)
context.world:play_sound("ENTITY_PLAYER_LEVELUP", loc.x, loc.y, loc.z, 0.7, 1.8)
-- UI feedback
context.player:show_action_bar("&a&lHealed! &7(" .. (uses - 1) .. " charges left)", 40)
-- Set cooldown
context.state.on_cooldown = true
context.scheduler:run_later(COOLDOWN_TICKS, function()
context.state.on_cooldown = false
end)
end
}
Walkthrough
-
Cooldown pattern -- Same boolean flag approach used in prop scripts.
on_equipinitializes the flag,on_right_clickchecks it, andscheduler:run_later()resets it after the cooldown. -
Charge system --
context.item:get_uses()reads a custom use counter from the item's PDC. Each use decrements it withset_uses(). When charges hit 0, the wand plays a fizzle sound and refuses to activate. -
Healing -- Rather than directly setting health, the script applies Regeneration II for 2 seconds. This works more naturally with Minecraft's health system and stacks properly with other effects.
-
Event cancellation --
context.event:cancel()prevents the right-click from interacting with nearby blocks (placing blocks, opening doors, etc.). -
UI feedback --
show_action_bar()tells the player the result of their action and remaining charges. Different messages for cooldown, out-of-charges, and successful heal make the item feel responsive.
Use /fmm giveitem <id> to get a properly tagged custom item. Items created through other means may lack the fmm_item_id PDC key, which means scripts will not activate for them.
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 behind a state-based cooldown check. -
Make props invulnerable by default. Unless you want the prop to be breakable, include the
on_left_clickdamage cancellation in every script. -
Use UPPER_CASE for Bukkit enums. Sound names and particle names must use the Bukkit enum constant format (e.g.
"FLAME", not"flame").
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. -
Lowercase sound/particle names.
"flame"does not work -- use"FLAME". The API converts to UPPER_CASE internally for particles, but Sound enum names must be exact. -
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 & Item API reference -- no invented aliases.
- Sound and particle names use UPPER_CASE Bukkit enum names.
- 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.state and scheduler:run_later.
Goblin Item Collection
The scripts/ folder ships with a set of 10 example item scripts themed around goblin weapons. Each script demonstrates different combat mechanics, particle effects, and API usage patterns.
| Script | Item | Effect |
|---|---|---|
goblin_golden_sword.lua | Golden Sword | 25% chance sweep -- damages and pushes all nearby enemies |
goblin_iron_axe.lua | Iron Axe | 20% chance spike -- launches target upward with dripstone visual |
goblin_bow.lua | Bow | Pulls hit mob towards player |
goblin_crossbow.lua | Crossbow | 33% chance firework barrage |
goblin_trident.lua | Trident | Ice dome encases target for 3s |
goblin_iron_hoe.lua | Iron Hoe | 15% chance poison clouds |
goblin_mace.lua | Mace | Meteor strikes from sky (right-click) |
goblin_spear.lua | Spear | Fire beam along aim (shift+right-click) |
goblin_shield.lua | Shield | Glass dome on block (reactive) |
goblin_iron_sword.lua | Iron Sword | Charging darkness ring (shift+right-click) |
These scripts work with vanilla-looking items. Each only needs a YML config with a material: field -- no .bbmodel file is required. For example, to enable the golden sword script, create a YML config like:
isEnabled: true
material: GOLDEN_SWORD
scripts:
- goblin_golden_sword.lua