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_character
  • game.this_player
  • game.this_enemy
  • game.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 bit32 calls when the values are non-negative: each bit32 call is a host call. For example, value / 16 instead of bit32.rshift(value, 4), and value % 16 instead of bit32.band(value, 15).
  • Prefer native features to Lua loops over entities. For example, read game.number_of_alive_enemies instead of looping over enemy slots to count them.
  • Every property read, property write and method call on an entity or on game is 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) and game.get_entity(id) return the same object for the same slot for as long as game is 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 when game changes (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, and acceleration_z are already in script units.
  • position_offset_y < 0 means the item is above the ground. position_offset_y == 0 means 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 100 is normal pitch and 80 is 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

  1. Getting Started
  2. Events And Script Timing
  3. Object Model
  4. Examples

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.