Skip to main content

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

  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.


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


Next Steps