Skip to main content

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

  1. Hook choice -- on_left_click fires when a player punches (left-clicks) the prop. Under the hood, this is an EntityDamageByEntityEvent on the prop's backing armor stand.

  2. Event guard -- context.event should always be present in this hook, but the guard is good practice.

  3. 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

  1. Constants at file scope -- OPEN_ANIMATION and CLOSE_ANIMATION are defined above the return table. This makes them easy to change for different model files that might use different animation names.

  2. State initialization -- on_spawn sets context.state.is_open = false. State persists across all hooks for this prop instance.

  3. Invulnerability -- The on_left_click hook cancels damage so the door cannot be accidentally broken.

  4. Toggle logic -- on_right_click checks context.state.is_open, stops any current animation, plays the appropriate animation, flips the state, and plays a sound. The stop_animation() call before play_animation() ensures clean transitions.

  5. 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

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

  1. Constants -- ZONE_RADIUS and PARTICLE_INTERVAL are at file scope for easy tuning.

  2. State initialization -- on_spawn sets up all state fields to nil / 0 before doing anything else.

  3. 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.

  4. Zone watching -- context.zones:watch() registers callbacks for player enter and leave. The callbacks increment and decrement a counter stored in context.state.

  5. 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.

  6. Cleanup -- on_destroy cancels the repeating task and unwatches the zone. While both are cleaned up automatically when the prop is removed, explicit cleanup is a best practice.

Particle performance

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

  1. Cooldown pattern -- Since FMM prop scripts do not have a built-in context.cooldowns API like EliteMobs, the example implements a simple cooldown using context.state.on_cooldown and scheduler:run_later(). The flag is set to true when the sound plays, and a delayed task resets it after COOLDOWN_TICKS.

  2. Sound playback -- context.world:play_sound() takes the Bukkit Sound enum name in UPPER_CASE, coordinates, volume, and pitch.

  3. Particle feedback -- Note particles appear above the prop when the sound plays, giving a visual cue.

  4. Invulnerability -- The on_left_click hook cancels damage as usual.

  5. Scheduler callback context -- The run_later callback receives later_context, a fresh context. We use later_context.state (not context.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.

Cooldown implementation

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

  1. Auto-start animation -- on_spawn immediately plays a looping "idle" animation. The false for blend means it starts fresh without blending from a previous animation. The true for loop means it repeats indefinitely.

  2. Ambient particles -- A repeating task spawns enchantment table particles above the prop every 2 seconds, creating a magical ambient effect.

  3. Cleanup -- on_destroy cancels 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

  1. Right-click to sit -- on_right_click gets the player from context.event.player, checks whether they are already a passenger (to avoid double-mounting), and calls context.prop:mount(player) to seat them on the prop's armor stand.

  2. Left-click to stand -- on_left_click cancels the damage event (invulnerability), then checks if the punching player is currently a passenger. If so, context.prop:dismount(player) ejects them.

  3. Passenger check -- context.prop:get_passengers() returns an array of entity tables. We compare UUIDs to find the interacting player in the list.

  4. 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

  1. Item check -- player:get_held_item() returns a table with type, amount, and display_name for the main hand item (or nil if empty). We compare held.type against "gold_ingot" (lowercase material name).

  2. Item consumption -- player:consume_held_item(1) removes one item from the player's main hand stack.

  3. Random buff -- The BLESSINGS table at file scope lists available positive effects. math.random(#BLESSINGS) picks one at random. player:add_potion_effect(effect, duration, amplifier) applies it -- 600 ticks is 30 seconds, amplifier 1 is level II.

  4. Cooldown -- The same boolean-flag-plus-scheduler pattern from the Sound-Emitting Prop example. A 30-second cooldown prevents shrine spam.

  5. 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

  1. 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.

  2. Lightning strike -- context.world:strike_lightning(x, y, z) strikes real (damaging) lightning at the player's location. The player's position is read from player.current_location.

  3. 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.

  4. Negative potion effects -- player:add_potion_effect("poison", 400, 1) applies 20 seconds of Poison II. Effect names are lowercase strings matching Bukkit's PotionEffectType names.

  5. Reward path -- When gold is offered, the shrine consumes one ingot and applies a random positive effect, mirroring the Blessing Shrine behavior.

  6. Cooldown -- A 30-second cooldown applies regardless of which branch was taken, preventing rapid-fire punishment or reward.

Entity spawning performance

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

  1. State guard -- context.state.is_spinning prevents multiple overlapping spin requests. The flag is set when the spin starts and cleared when the scheduled stop fires.

  2. Timed animation -- play_animation(SPIN_ANIMATION, true, false) plays the animation once (no loop). The scheduler:run_later(100, ...) call stops the animation after exactly 5 seconds, in case the animation itself is longer or looping.

  3. Mechanical sounds -- BLOCK_CHAIN_PLACE gives a clicking/mechanical start sound; BLOCK_CHAIN_FALL gives a winding-down stop sound. Adjust pitch to taste.

  4. Callback context -- The run_later callback uses later_context (not context) for all state and world access. This is the correct pattern for scheduler callbacks.

Animation names

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

  1. 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.

  2. 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.

  3. 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.

  4. No leave callback -- The nil second argument to context.zones:watch() means we do not care when players leave the zone.

  5. Cleanup -- on_destroy unwatches the zone. While zones are cleaned up automatically when the prop is removed, explicit cleanup is a best practice.

Scare design

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

  1. 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 .yml file in EliteMobs' custombosses folder.

  2. Graceful fallback -- spawn_elitemobs_boss() returns nil if 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.

  3. 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.

  4. Cooldown -- A 10-second cooldown prevents players from flooding the area with goblin warriors. Adjust COOLDOWN_TICKS based on your gameplay needs.

  5. 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.

EliteMobs integration

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

  1. State initialization -- on_equip sets wave_active = false to prevent multiple overlapping shockwaves. This hook fires when the player equips the sword.

  2. 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.

  3. Expanding wave -- scheduler:run_repeating() runs every 2 ticks. Each tick, the wave radius grows by WAVE_SPEED. Particles are spawned in a ring, but only within the cone angle.

  4. 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. A destroyed_blocks table prevents double-processing.

  5. 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. The if entity.damage then guard is critical -- get_nearby_entities() returns ALL entities (armor stands, items, etc.), not just living ones.

  6. Combat effects -- Hit entities take damage, get knocked back in the wave's direction, and receive 3 seconds of Slowness II.

  7. Player feedback -- show_action_bar() displays "Frost Shockwave!" for 40 ticks. Glass break sounds advance with the wave front.

  8. Cleanup -- When the radius exceeds WAVE_MAX_RADIUS, the repeating task cancels itself and resets the wave_active flag.

Entity type safety

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

  1. Cooldown pattern -- Same boolean flag approach used in prop scripts. on_equip initializes the flag, on_right_click checks it, and scheduler:run_later() resets it after the cooldown.

  2. Charge system -- context.item:get_uses() reads a custom use counter from the item's PDC. Each use decrements it with set_uses(). When charges hit 0, the wand plays a fizzle sound and refuses to activate.

  3. 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.

  4. Event cancellation -- context.event:cancel() prevents the right-click from interacting with nearby blocks (placing blocks, opening doors, etc.).

  5. 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.

Getting custom items

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_spawn that 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 read context.state.is_open in on_right_click but never set it in on_spawn, it will be nil and your comparisons may behave unexpectedly.

  • Cancel repeating tasks when done. Every run_repeating should have a matching cancel in on_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_tick lightweight. 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_click damage 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 context inside 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_repeating in on_spawn but never cancel it, the task runs until the prop is removed.

  • Not initializing state in on_spawn. Reading context.state.x before setting it returns nil, which may break your logic silently.

  • Wrong animation names. If play_animation("open") returns false, 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 return statement. 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:

  1. The file returns exactly one table with api_version = 1.
  2. Every hook name matches an entry in the prop hook list or item hook list exactly.
  3. context.event is guarded with if context.event then before calling cancel().
  4. context.state fields are initialized in on_spawn.
  5. Every scheduler:run_repeating(...) call has a matching scheduler:cancel(...) in on_destroy.
  6. Scheduler callbacks use the callback's own context parameter, not the outer context.
  7. on_game_tick hooks gate expensive work behind a check.
  8. All method names exist in the Prop & Item API reference -- no invented aliases.
  9. Sound and particle names use UPPER_CASE Bukkit enum names.
  10. 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_click with context.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.

ScriptItemEffect
goblin_golden_sword.luaGolden Sword25% chance sweep -- damages and pushes all nearby enemies
goblin_iron_axe.luaIron Axe20% chance spike -- launches target upward with dripstone visual
goblin_bow.luaBowPulls hit mob towards player
goblin_crossbow.luaCrossbow33% chance firework barrage
goblin_trident.luaTridentIce dome encases target for 3s
goblin_iron_hoe.luaIron Hoe15% chance poison clouds
goblin_mace.luaMaceMeteor strikes from sky (right-click)
goblin_spear.luaSpearFire beam along aim (shift+right-click)
goblin_shield.luaShieldGlass dome on block (reactive)
goblin_iron_sword.luaIron SwordCharging darkness ring (shift+right-click)
No model required

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

Next Steps