Skip to content

Authoring Lua Items

EmoTracker Community edited this page Apr 8, 2026 · 2 revisions

Authoring Lua — Lua Items

A LuaItem is a fully-scripted item type whose entire behavior — click handlers, code provision, save/load, and state — is defined in Lua rather than JSON. Use it when none of the built-in item types fit and you need to author the item's behavior from scratch.

See Authoring Items for the JSON-driven item types you should reach for first. Lua Items are an escape hatch for cases the built-in types can't express; they're more work and don't get the same UI affordances out of the box.

When to use a LuaItem

The vast majority of items in a typical pack are simple toggles, progressives, or consumables that work fine via the JSON definitions. Reach for a LuaItem only when:

  • You need state shapes the built-in types don't support — for example a counter with multiple independent dimensions, or an item that derives its state from other items.
  • The mouse-click behavior is more complex than "advance / increment" — for example a click that triggers a multi-step state machine.
  • You need custom save/load logic that the built-in items don't expose.
  • You need to be notified when the item's state changes from any source (other Lua, autotracker, etc.) and react.

If your case fits any of the built-in item types, use those instead. They're smaller to author, render correctly in capture pickers and item grids, and integrate cleanly with the save system.

The recommended approach: the CustomItem SDK base class

The EmoTracker-Service repository ships an SDK Lua module that provides a CustomItem base class. It wraps the raw ScriptHost:CreateLuaItem() API in an OO-style class with overridable methods, transaction-backed properties, and a working example. For 95% of custom items you'll write, this is the easier and recommended path — you only need to drop down to the bare-metal API documented in Working directly with ScriptHost:CreateLuaItem when the SDK genuinely doesn't fit.

Files in the SDK

File What it provides
sdk/lua/custom_item/class.lua A small generic OO class system (Class, class(), :extend()). Sourced from jonstoler/class.lua and licensed freely.
sdk/lua/custom_item/custom_item.lua Defines the CustomItem base class. Wires up every LuaItem callback to a corresponding method on your subclass and adds setProperty / getProperty helpers for transaction-backed (undo-aware) state.
sdk/lua/custom_item/examples/toggle/toggle.lua A complete ToggleItem example built on CustomItem.
sdk/lua/custom_item/examples/toggle/init.lua A minimal init.lua showing how to load the SDK files and instantiate the toggle.

Adding the SDK to your pack

Copy class.lua and custom_item.lua from the SDK directory into your pack — typically into scripts/:

my_pack/
├── scripts/
│   ├── init.lua
│   ├── class.lua          # copied from sdk/lua/custom_item/class.lua
│   ├── custom_item.lua    # copied from sdk/lua/custom_item/custom_item.lua
│   └── ...your own custom item files...

Then load them from init.lua before any file that uses CustomItem:

-- scripts/init.lua
ScriptHost:LoadScript("scripts/class.lua")
ScriptHost:LoadScript("scripts/custom_item.lua")
ScriptHost:LoadScript("scripts/items/special_sword.lua")

Once those two files have run, the Class, class, and CustomItem globals are available to the rest of your scripts.

Subclassing CustomItem

To define a new custom item type, create a class that extends CustomItem:

MyItem = CustomItem:extend()

function MyItem:init(name, ...)
    self:createItem(name)   -- creates the underlying LuaItem and wires it up
    -- ...your own initialization
end

Two things must happen in your init:

  1. Call self:createItem(name). This creates the underlying LuaItem (via ScriptHost:CreateLuaItem()), assigns it to self.ItemInstance, sets the display name, and binds every LuaFunction callback so that the runtime ends up calling your subclass methods.
  2. Initialize your own state. Set images, default property values, etc.

After init finishes, your item is registered and participates in code lookups, the items grid, the save system, and so on.

Methods you override

CustomItem provides default no-op implementations for every callback the runtime might invoke. You override the ones you need:

Method When it's called What to return
onLeftClick(self) Left click on the item nothing
onRightClick(self) Right click on the item nothing
canProvideCode(self, code) Asked whether this item could ever provide a code (used by capture pickers, etc.) true / false
providesCode(self, code) Asked how many of code this item currently provides integer count >= 0
advanceToCode(self, code) Asked to advance the item until it provides code (e.g., when a section that hosts this item is cleared) nothing
save(self) Save file is being written a Lua table of plain values
load(self, data) Save file is being loaded; data is the table you returned from save true for success, false to fail the load
propertyChanged(self, key, value) A transaction-backed property changed via setProperty nothing

The runtime invokes these via the LuaItem callbacks the SDK already wired up, so you never need to deal with OnLeftClickFunc etc. directly.

Transaction-backed properties

The SDK exposes two helpers for undo-aware state via the underlying LuaItem:

self:setProperty(key, value)   -- writes a value through the transaction system
local v = self:getProperty(key) -- reads it back

Properties set via setProperty participate in the application's undo system, so the user's Ctrl/Cmd+Z rolls them back along with other tracker actions. They also fire the propertyChanged callback whenever they change, which lets you keep derived state (like the current icon) in sync from one place.

For state that doesn't need undo support, you can store ordinary Lua fields directly on self (self.code = "...") — those work fine but aren't undoable.

A complete example: the SDK's ToggleItem

This is the toggle example shipped at sdk/lua/custom_item/examples/toggle/toggle.lua, reproduced here:

ToggleItem = CustomItem:extend()

function ToggleItem:init(name, code, imagePath)
    self:createItem(name)
    self.code = code
    self:setProperty("active", false)
    self.activeImage = ImageReference:FromPackRelativePath(imagePath)
    self.disabledImage = ImageReference:FromImageReference(self.activeImage, "@disabled")
    self.ItemInstance.PotentialIcon = self.activeImage

    self:updateIcon()
end

function ToggleItem:setActive(active)
    self:setProperty("active", active)
end

function ToggleItem:getActive()
    return self:getProperty("active")
end

function ToggleItem:updateIcon()
    if self:getActive() then
        self.ItemInstance.Icon = self.activeImage
    else
        self.ItemInstance.Icon = self.disabledImage
    end
end

function ToggleItem:onLeftClick()
    self:setActive(true)
end

function ToggleItem:onRightClick()
    self:setActive(false)
end

function ToggleItem:canProvideCode(code)
    if code == self.code then
        return true
    else
        return false
    end
end

function ToggleItem:providesCode(code)
    if code == self.code and self:getActive() then
        return 1
    end
    return 0
end

function ToggleItem:advanceToCode(code)
    if code == nil or code == self.code then
        self:setActive(true)
    end
end

function ToggleItem:save()
    local saveData = {}
    saveData["active"] = self:getActive()
    return saveData
end

function ToggleItem:load(data)
    if data["active"] ~= nil then
        self:setActive(data["active"])
    end
    return true
end

function ToggleItem:propertyChanged(key, value)
    self:updateIcon()
end

Using a subclass

Once ToggleItem is loaded, instantiating one is a single call:

-- scripts/init.lua
ScriptHost:LoadScript("scripts/class.lua")
ScriptHost:LoadScript("scripts/custom_item.lua")
ScriptHost:LoadScript("scripts/toggle.lua")

local newItem = ToggleItem("Test", "test", "images/test.png")

The constructor call (ToggleItem("Test", ...)) goes through the class system's __call metamethod, which creates a new instance and calls init on it. The new item is registered with the runtime via the underlying createItem call.

Note how cleanly the propertyChanged pattern interacts with setProperty: every state change goes through setActivesetProperty("active", ...) → triggers propertyChanged → calls updateIcon. There's exactly one place that decides which icon to show, regardless of where the state change originated (mouse click, save load, autotracker, undo).

What CustomItem gives you over the raw API

Concern Raw LuaItem CustomItem SDK
Wiring callbacks Manually assign each LuaFunction field One call to self:createItem(name)
State organization Free-form ItemState table Class fields + setProperty/getProperty
Undo support None Transaction-backed via setProperty
Subclassing Awkward — closures over a base item Idiomatic with :extend() and :init()
Reacting to state changes Have to remember to call your update logic from every mutator Centralized via propertyChanged

You can still mix the two — your CustomItem subclass has access to self.ItemInstance, which is the raw LuaItem, so you can read and write its other properties directly. The SDK is just a thin convenience layer.

Working directly with ScriptHost:CreateLuaItem

If for some reason the SDK doesn't fit — you want a single-file item with no class scaffolding, or you're building tooling that operates on items dynamically — you can still drop down to the underlying LuaItem API directly.

Create one with ScriptHost:CreateLuaItem():

local item = ScriptHost:CreateLuaItem()
item.Name = "My Custom Item"
item.ItemState = { acquired = false, level = 0 }

The created item is automatically registered with the item database, so it participates in code lookups, capture pickers (if Capturable is set), and the save system. You'll typically wire its callbacks up immediately after creating it.

Properties on a LuaItem

LuaItems inherit the standard item base fields and add a few of their own. The Lua-side names match the C# names exactly (NLua exposes them via property accessors).

Inherited from the item base

Property Type Description
Name string Display name. Required for the item to be useful.
Capturable boolean Whether the item can be picked in a section capture slot.
MaskInput boolean If true, only the visible (non-transparent) pixels of the icon are clickable.
IgnoreUserInput boolean If true, mouse clicks are ignored.
Icon ImageReference The currently displayed icon. Construct via ImageReference:FromPackRelativePath(...).
PotentialIcon ImageReference The icon shown in faded form when the item is "potentially providing" a code (used by location capture slots).
BadgeText string Optional text drawn over the item icon (e.g., a count).
BadgeTextColor string Color for the badge text in web color format.

LuaItem-specific

Property Type Description
ItemState LuaTable A free-form Lua table you can use as the item's state.
OnLeftClickFunc function Lua function called on left click. Signature: function(item) end.
OnRightClickFunc function Lua function called on right click. Signature: function(item) end.
ProvidesCodeFunc function Lua function returning the count this item provides for a given code. Signature: function(item, code) return count end.
CanProvideCodeFunc function Lua function returning true/false for whether this item could ever provide the given code. Signature: function(item, code) return bool end.
AdvanceToCodeFunc function Lua function called when the runtime asks the item to advance toward providing a particular code (e.g., when the user clears a section that hosts this item). Signature: function(item, code) end.
SaveFunc function Lua function returning a Lua table to be persisted in the save file. Signature: function(item) return table end.
LoadFunc function Lua function called with the loaded table during save loading. Signature: function(item, data) end.
PropertyChangedFunc function Lua function called whenever any of the item's WPF/Avalonia-bound properties change. Signature: function(item, propertyName) end.

The item argument passed into each callback is the LuaItem itself, so the callbacks can read and write its ItemState and other properties.

A bare-API toggle (for comparison)

Here's the same toggle behavior the SDK provides, written without the SDK to illustrate what's actually happening underneath:

function create_toggle(name, code, image_path)
    local active_image = ImageReference:FromPackRelativePath(image_path)
    local disabled_image = ImageReference:FromImageReference(active_image, "@disabled")

    local item = ScriptHost:CreateLuaItem()
    item.Name = name
    item.ItemState = { active = false, code = code }
    item.PotentialIcon = active_image
    item.Icon = disabled_image

    local function update_icon(self)
        if self.ItemState.active then
            self.Icon = active_image
        else
            self.Icon = disabled_image
        end
    end

    item.OnLeftClickFunc = function(self)
        self.ItemState.active = true
        update_icon(self)
        self:InvalidateAccessibility()
    end

    item.OnRightClickFunc = function(self)
        self.ItemState.active = false
        update_icon(self)
        self:InvalidateAccessibility()
    end

    item.CanProvideCodeFunc = function(self, c)
        return c == self.ItemState.code
    end

    item.ProvidesCodeFunc = function(self, c)
        if c == self.ItemState.code and self.ItemState.active then return 1 end
        return 0
    end

    item.AdvanceToCodeFunc = function(self, c)
        if c == nil or c == self.ItemState.code then
            self.ItemState.active = true
            update_icon(self)
        end
    end

    item.SaveFunc = function(self) return { active = self.ItemState.active } end
    item.LoadFunc = function(self, data)
        if data and data.active ~= nil then
            self.ItemState.active = data.active
            update_icon(self)
        end
    end

    return item
end

local newItem = create_toggle("Test", "test", "images/test.png")

The SDK version is shorter, has built-in undo support, and is easier to subclass. Reach for the bare API only if you have a reason not to use the SDK.

Tips and pitfalls

  • Use the SDK unless you have a specific reason not to. It's the recommended path. The bare API exists for cases where the SDK pattern doesn't fit, or for one-off helpers.
  • Set Icon immediately after creation (or in your subclass's init). A LuaItem with no icon is invisible in the items grid and capture pickers. Always assign an Icon (and probably a PotentialIcon) before the item participates in any UI.
  • Centralize icon updates. With the SDK, the propertyChanged callback is the natural place to call updateIcon. Without the SDK, call your icon-update routine from every mutator.
  • setProperty triggers propertyChanged automatically. Don't call your update logic directly from your mutators if you're already going through setProperty — that double-fires.
  • Keep saved tables simple and serializable. Stick to plain values, strings, booleans, numbers, and nested tables of those.
  • save and load should be symmetric. Fields you save should be read in load, with sensible defaults for missing fields (since old save files might not have them).
  • providesCode returns a count, not a boolean. Return 0 for "doesn't provide", >= 1 for "provides this many".
  • Be defensive in load. Check data for nil and missing fields — older save files may not have everything your current item expects.
  • Don't print from propertyChanged. It fires constantly and will flood the script console.

See also

Clone this wiki locally