Lua Scripting¶
For scene hooks, rollback-safe game variables, camera overrides, indexed tilemaps and isolated background effects, see Scripted stages. For temporary Lua ownership of simulation and selective actor updates, see Script-owned cinematics.
Lua scripting is the modding layer for behaviour that cannot be expressed cleanly with art, palettes, audio replacements, logic events, or animation behaviour alone. Use it for custom movement, conditional attacks, AI decisions, projectiles, parries, recovery states, hit reactions, and global runtime rules.
The scripting bridge is intentionally close to the game engine. A script can read and write the same model fields used by the original movement controllers, then either let the original controller continue or stop it and fully own the frame.
Where Scripts Live¶
Put Lua files in your pack's Scripts folder:
Editor/<Pack Name>/Scripts/<script name>.lua
Scripts are referenced from entity logic.json data. A script for one particular move normally
lives inside that animation's script field:
{
"script": {
"packName": "My Pack",
"scriptName": "Aerial Control"
}
}
An enemy-wide decision script instead uses the entity-level aiScript field:
{
"aiScript": {
"packName": "My Pack",
"scriptName": "Enemy AI"
}
}
The engine uses this AI automatically whenever that entity is spawned as an enemy, including from a level in a custom mod. Nothing needs to be added to the level spawn itself. See Enemy AI for the execution order and examples.
Runtime Model¶
Every script runs as a MoonSharp module and receives a global game object. From there you can reach the current context:
game.this_charactergame.this_playergame.this_enemygame.this_item
You can also resolve entities by object ID:
game.get_player(id)game.get_enemy(id)game.get_character(id)game.get_item(id)game.get_sub_item(id)
Objects are layered:
Player or Enemy
-> Character
-> Item
-> SubItem
The Lua bridge flattens the layers. A player exposes player input fields, character combat fields, item physics fields, and sub-item animation fields on the same object. This means game.this_player.position_x, game.this_player.health, and game.this_player.current_animation_frame all work.
Script Structure¶
A script file is a Lua chunk that returns its update function. The engine runs the chunk once, the first time the script is used, and then calls the returned function on every update:
local SPEEDS = {2, 3, 4} -- built once
local function chase(enemy, target)
-- ...
end
return function()
local enemy = game.this_enemy
if enemy == nil then return true end
chase(enemy, game.get_player(0))
return false
end
Everything outside the function (require calls, constant tables, helper functions) runs once.
Everything inside it runs on every call. Every example in these docs uses this shape; a fragment
shown on its own is an excerpt from inside an update function.
Fetch The Context On Every Call¶
The same function runs for every entity that uses the script, and game.this_character,
game.this_player, game.this_enemy, and game.this_item change from one call to the next.
Fetch the context entity inside the function, at the start of every call:
return function()
local character = game.this_character
if character == nil then return true end
-- ...
return true
end
Helper functions that need the entity take it as a parameter, like chase above. Alternatively,
declare an upvalue before the helpers and assign it first thing in the function:
local e -- assigned at the start of every call
local function face(target)
e.is_facing_right = target.position_x > e.position_x
end
return function()
e = game.this_enemy
if e == nil then return true end
face(game.get_player(0))
return true
end
Never read the context at the top level of the chunk: that code runs only once, for whichever entity happened to use the script first.
Keep State In Entity And Game Fields¶
Locals outside the function are shared by every entity that uses the script, and they are not
rolled back with the game state. Keep them for constants, lookup tables that are never modified,
helper functions, and required modules. Never store gameplay state there: no counters, timers,
flags, or remembered targets. A value that must survive from one call to the next belongs in an
entity or game field, such as custom_value_1 on the entity or game.custom_value_1, which are
saved and restored with the game state.
Initialise Every Local¶
In this Lua runtime a bare declaration such as local a, b inside a loop or a function body can
keep the value it had on a previous pass instead of starting as nil. Code that then tests only one
of the two values can compare a stale number against nil and stop the script with an error. Always
give locals an explicit value:
local target, distance = nil, nil
and check every value a branch uses before using it.
The Return Value¶
The update function's return value matters:
return true
Continue the normal update path after the script. For an animation script, this lets animation behaviour or the fallback/original controller run after your Lua code.
return false
Stop the normal update path for this entity on this frame. Use this when the script has already performed movement, collision checks, hit checks, landing logic, or state transitions. Many complex projectile, custom jump, and aerial special scripts return false because they replace the built-in movement controller.
If the function exits without returning a boolean, MoonSharp returns nil. In this engine that behaves like false when the C# side reads result.Boolean, so always return true or false deliberately.
For an entity-level enemy aiScript, the same values have a narrower meaning: false skips the
enemy's native controller for that frame, while true lets it continue. The AI script runs before
any script belonging to the current animation.
Some hooks read the value differently: a pickup or special-move script accepts with true, a HUD
script returns sprite commands, and a visual script returns a table of
offsets. Hooks whose return value is ignored say so where they are described.
Performance¶
Scripts can run once per entity per frame, so their cost adds up:
- Re-running a chunk rebuilds every table and closure it defines. The update-function form builds them once; keep constant tables and helper functions outside the function.
- Prefer plain arithmetic to
bit32calls when the values are non-negative: eachbit32call is a host call. For example,value / 16instead ofbit32.rshift(value, 4), andvalue % 16instead ofbit32.band(value, 15). - Prefer native features to Lua loops over entities. For example, read
game.number_of_alive_enemiesinstead of looping over enemy slots to count them. - Every property read, property write and method call on an entity or on
gameis a host call, and so is every call to a small Lua helper: read a property once into a local when a branch tests it several times, and inline one-line helpers on paths that run for every enemy. game.get_player(id),game.get_enemy(id),game.get_item(id)andgame.get_entity(id)return the same object for the same slot for as long asgameis the same object, whether or not the slot is in use. A script may keep those objects in a table, as long as it starts over whengamechanges (if game ~= cached_game then ... end). Keep only the objects there, never values read from them: those belong to the frame being simulated.
Deprecated Form¶
Older scripts return their result directly from the chunk, so the engine runs the whole chunk,
helpers and tables included, on every call. That form still works, but the engine logs a one-time
warning for each such script: "Lua script ... returns a result instead of an update function".
To convert one, move its constants and helper functions to the top of the file, wrap the rest in
return function() ... end, and pass the context entity to any helper that read it as an upvalue.
Custom HUD and pickups¶
Playable entities can also declare playerUpdateScript, run once before their
controller in every animation, and specialMoveScript, which owns special
admission and entry cost. Both use game.this_player. The update hook's return
value is ignored. The special hook must explicitly return true to accept;
false or an error rejects the input. It runs after direction/height and move
availability checks, replaces the native 8-HP admission rule, and suppresses
the later native special health charge. Check eligibility before modifying
state. Scripted cancels can call player.try_special_move() to use this same
path. Custom move scripts must remove their own fixed health deductions.
invincibleOffensiveSpecial: true initializes offensive specials as invulnerable
and suppresses native Blaze's later vulnerability window. Custom scripts and
events must retain invulnerability through their own timeline and restore it
when the move ends. These entity hooks are independent of campaign mechanics
selection; their state belongs in rollback-owned fields, never Lua globals.
A playable entity can add hudScript at the root of its logic.json:
"hudScript": { "packName": "My Pack", "scriptName": "My HUD" }
The view calls the script's update function for that player while its health bar is visible. It
runs in a separate Lua VM with a copied hud table; there is no mutable game
object. Available fields are frame, player_number (1–4), health, lives, score,
custom_dec_buffer_14, custom_bullet_lifetime_dec_buffer_18, base_x,
base_y (the bar's relocated pixel origin), screen_width (320 or 400),
and entity_name. weapon_health and weapon_max_health report the held weapon's
remaining durability and configured health capacity. Both are zero when no
health-based weapon is held. These are read-only snapshot values; displaying them
does not initialize or consume durability. The function returns up to
16 sprite commands. Each command names an entity frame from a loaded pack and
uses x/y in pixels relative to the bar origin. pack defaults to the
player's pack; scale_percent defaults to 100 (normal size). Use an integer
percentage such as 50 for half size; gameplay Lua accepts integer literals.
return function()
if hud.health <= 0 then return {} end
return {
{entity = 'HUD Art', image = 0, x = 112, y = 8},
{entity = 'HUD Art', image = 1, x = 120, y = 8, scale_percent = 50}
}
end
The art entity uses ordinary Entities/<Name>/ numbered PNGs, pivots,
spriteSettings.json, animations.json, and logic.json, so it works in raw
and exported packs. The HUD script only draws; keep gameplay counters in the
rollback-owned player or game model. Missing art or an invalid command is
skipped. The view hides commands when the player bar is hidden, during continue
selection, and when the player is gone.
An item can add pickupScript at the root of its logic.json:
"pickupScript": { "packName": "My Pack", "scriptName": "Collect Token" }
When a player attempts to collect it, the gameplay script's update function runs with
game.this_player and game.pickup_item set. Check eligibility before changing
state. Return true to apply the effect and consume the item. Return false
to leave it in the world, including for characters that do not recognize it.
Failure also leaves it unconsumed. For example:
return function()
local player = game.this_player
if player == nil or player.packName ~= 'My Pack' then return false end
player.customValue1 = player.customValue1 + 1
return true
end
The pickup must still be a vulnerable, active item with a suitable body box and placement. A custom item with no pickup script and no Classic weapon or goodie identity is ignored. A pickup script on a Classic-derived item replaces that item's usual collection effect.
Use the script base name without .lua. Subfolders are supported by the loader, but simple flat script names are easier to maintain.
Naming Rules¶
The C# runtime uses camelCase and PascalCase internally. Lua scripts can usually use:
- documented
snake_case:current_animation_frame - exact bridge names:
currentAnimationFrame - mixed names that camelify cleanly:
positionOffset_y
MoonSharp resolves a Lua member by trying the exact name, an uppercase-first variant, a camelified variant, and an uppercase-first camelified variant. The docs use snake_case for normal fields because it is the clearest style for Lua.
When a script reads or writes object_mode, use the Entity Types and Behaviour Modes reference for the Classic behaviour values. The Character Editor hides the technical numbers in its pickers, but scripts and hand-edited JSON still use the numeric objectMode values.
Some low-level fields expose original memory suffixes, such as enemySpecific_C0 or customVelocity_DecBuffer_0_4_X. Those suffixes are part of the C# member name and do not camelify cleanly from plain snake_case. The API reference marks them with their exact bridge spelling; copy those names exactly.
Units And Scaling¶
The engine stores world coordinates, velocities, impulses, and accelerations in raw 16.16 fixed point (65536 raw units per pixel). The Lua bridge divides these by 1000 when reading and multiplies by 1000 when writing, so one pixel is approximately 65.536 script units. Camera coordinates are pixels; custom_z_order is raw engine depth. Check each property's units before transferring values between them.
For script authors, this means:
position_x,position_y,velocity_x,velocity_y,impulse_x,impulse_y,impulse_z,acceleration_x,acceleration_y, andacceleration_zare already in script units.position_offset_y < 0means the item is above the ground.position_offset_y == 0means grounded.- Animation IDs in Lua are editor-facing IDs. Internally the engine stores them doubled; the bridge handles that conversion.
- Animation frames are integer frame counters inside the current animation logic entry.
- Sound pitch is a percentage, so
100is normal pitch and80is lower.
Gameplay Lua uses signed 32-bit integer arithmetic. Division truncates toward zero, and multiplication can overflow before a later division reduces the result. For fixed-point scaling, split the quotient and remainder when the intermediate product would exceed the integer range:
local function rescale(value, numerator, denominator)
local quotient = value / denominator
local remainder = value - quotient * denominator
return quotient * numerator + remainder * numerator / denominator
end
Both smaller products must still fit in 32 bits. Derive the remainder as shown;
% uses a different convention for negative operands. Avoid dividing away the
fraction before multiplying, which loses precision.
Available Lua Standard Library¶
Scripts run in a limited sandbox. The registered modules are basic Lua functions, strings, tables, table iterators, bit32, and load/require support. The math, os, io, coroutine, debug, and JSON modules are not available to gameplay scripts.
There is no math table in any script: gameplay, AI, pickup, HUD and level visual scripts alike. math is nil, so math.floor(x) raises "attempt to index a nil value". The error only reaches the game log; in game the script just stops doing its work for that update, which looks like a frozen palette cycle, an idle enemy or a missing HUD element rather than a crash. Numbers are 32-bit integers and / already truncates toward zero, so floor is rarely needed; write the small helpers you need yourself, as below.
Write tiny helpers when needed, outside the update function so they are defined once:
local function abs(value)
if value < 0 then
return -value
end
return value
end
API Reference¶
Start Here¶
Health-based weapons may set weaponHudFallbackDelay in logic.json to a positive number of seconds. This enables held-weapon fallback in the interacted-entity HUD row after that delay; enemy interactions take priority. Zero keeps usage-triggered display. The view reads snapshot state without adding simulation state.