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 prop 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. -
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.
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 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.
- 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.
Next Steps
- Getting Started -- file structure, hooks, first script walkthrough, templates
- Prop API -- full API reference for all context tables
- Troubleshooting -- common issues, debugging tips