Getting Started With Lua

A Lua script is a text file with the .lua extension. The game loads it from a mod pack when an animation, entity, or pack-level update hook references it.

Minimal Script

A script returns its update function. The game runs the file once, then calls that function on every update; the function's return value tells the engine what to do next. See Script Structure.

return function()
    local character = game.this_character

    -- The same script can be accidentally attached to an item or global hook.
    -- Guarding the context keeps the script safe.
    if character == nil then
        return true
    end

    -- This field is set by the combat controller after hitbox collision checks.
    -- If the original controller has not run yet this frame, it may still reflect
    -- the previous frame, so use it for reactions, not for guaranteed startup.
    if character.is_hitting_someone then
        character.play_sound("My Pack", "impact-heavy", 1)
    end

    -- true means "continue with animation behaviour or original controller".
    return true
end

Add The Script To A Pack

  1. Create the script:
Editor/<Pack Name>/Scripts/Impact Sound.lua
  1. Open the entity in the Character Editor.
  2. Select the animation that should run the script.
  3. Assign the script in the animation logic.
  4. Test the animation in game.

The saved logic.json entry looks like:

{
  "script": {
    "packName": "My Pack",
    "scriptName": "Impact Sound"
  }
}

The game Global

The game object is the entry point for the current update. Read the context objects from it inside the update function, on every call:

local character = game.this_character
local player = game.this_player
local enemy = game.this_enemy
local item = game.this_item

Only some of these are valid in a given context:

Context Valid objects
Player animation script this_player, this_character
Enemy animation script this_enemy, this_character
Item animation script this_item
Pack-level player hook this_player, this_character
Pack-level enemy hook this_enemy, this_character
Pack-level item hook this_item

Always check context-specific objects for nil before using them.

First Safe Reads

Start with scripts that read state and call one helper:

return function()
    local player = game.this_player

    if player == nil then
        return true
    end

    -- pressed_button_b is true only on the frame the attack button was pressed.
    if player.pressed_button_b then
        player.play_sound("My Pack", "debug-click", 0)
    end

    return true
end

First Safe Writes

Then move to state changes. Frame 0 is useful for one-time setup because the animation frame is advanced later by the animation controller.

return function()
    local character = game.this_character

    if character == nil then
        return true
    end

    if character.current_animation_frame == 0 then
        -- Stop inherited horizontal motion at the start of this animation.
        character.velocity_x = 0
        character.impulse_x = 0
    end

    return true
end

Replacing The Controller

If your script performs its own physics, collisions, and hit checks, return false.

return function()
    local item = game.this_item

    if item == nil then
        return true
    end

    -- Item projectiles normally need manual movement when Lua owns the frame.
    local should_destroy = item.check_projectile_hits()

    item.velocity_x = item.velocity_x + item.impulse_x
    item.velocity_y = item.velocity_y + item.impulse_z
    item.inner_timer = item.inner_timer - 1

    if item.inner_timer <= 0 then
        should_destroy = true
    end

    if item.update_position_and_check_world_collisions() then
        item.play_sound("Classic", "SE10", 0)
        should_destroy = true
    end

    if should_destroy then
        item.set_animation(48)
        item.is_active = false
    end

    -- false stops fallback item physics so the projectile is not moved twice.
    return false
end

Debugging

Use print() for temporary logs: the text goes to the game log (Player.log, or the Unity console in the editor) with a [Lua] prefix, and while debug is enabled (as when testing a pack from the editor) it is also shown on screen, at most one message per second, so a print every frame does not flood the screen. Script errors are reported through the game's error display and log, with the script name and line; this includes runtime errors in background visual scripts and HUD scripts, which stop running after their first error. If a script appears to do nothing:

  • check the filename and scriptName
  • check that scriptName omits .lua
  • check the pack name exactly
  • confirm the animation actually reaches the scripted frame
  • confirm the entity is the expected type
  • guard against nil context objects
  • return the update function from the script, and an explicit boolean from that function
  • avoid math.*, os.*, and io.*, which are not registered in the gameplay sandbox
  • look for a "script exceeded instruction budget" error: the script ran a loop that does not end (see below)

Lua Pitfalls

No math module

The game's Lua uses 32-bit integers and ships no math module in any script VM: gameplay, background visual (visualScript) and HUD scripts alike. math is nil, so math.floor(x) fails with "attempt to index a nil value". Division already truncates toward zero, so a / b is the integer result; write small helpers for the rest:

local function abs(x) if x < 0 then return -x end return x end
local function min(a, b) if a < b then return a end return b end
local function max(a, b) if a > b then return a end return b end

For random numbers in gameplay scripts use game.random(range), which is replay and netplay safe.

Every call has an instruction budget

Each call into a script (its update function, a hook, a HUD or background visual script) may run at most 10 million Lua instructions, counting everything it calls. That is far more than a normal update needs (shipped scripts use tens of thousands), so only a loop that never ends reaches it: while true do end, a repeat whose condition never becomes true, or a for loop whose bounds come from a broken value. The call then stops with a "script exceeded instruction budget" error, shown on screen with the script line. The game stays responsive, but whatever the script changed before it was stopped stays changed.

A gameplay script that exceeds the budget is disabled for the rest of the run, so its entity or stage carries on without it; a HUD or background visual script stops, as after any other error. Fix the loop and start a new run. Waiting for something across frames belongs in entity or game fields checked once per update, never in a loop inside one update.

Initialise every local

A local declared without a value, especially several at once (local a, b), has been seen to keep a value from an earlier pass of the same loop or update in this Lua build, instead of starting as nil. Code that then tests only one of them reads a stale value. Always give locals an explicit value, and check every value a branch uses:

-- risky: j and p may not start as nil
local j, p

-- safe
local j, p = nil, nil
if k ~= nil then j, p = find_slot(k) end
if j ~= nil and p ~= nil then
    -- use j and p
end

Loading other files

require("Pack/module") loads Scripts/module.lua from that pack (a name without a pack prefix uses the script's own pack). dofile and loadfile take a "Pack/path/file.lua" path. Paths must stay inside the pack: .., absolute paths, drive letters and \ separators are refused, and only .lua files can be loaded.

See Troubleshooting.