-
Notifications
You must be signed in to change notification settings - Fork 8
Authoring 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.
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 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.
| 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. |
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.
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
endTwo things must happen in your init:
-
Call
self:createItem(name). This creates the underlyingLuaItem(viaScriptHost:CreateLuaItem()), assigns it toself.ItemInstance, sets the display name, and binds everyLuaFunctioncallback so that the runtime ends up calling your subclass methods. - 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.
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.
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 backProperties 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.
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()
endOnce 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 setActive → setProperty("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).
| 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.
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.
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).
| 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. |
| 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.
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.
- 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
Iconimmediately after creation (or in your subclass'sinit). A LuaItem with no icon is invisible in the items grid and capture pickers. Always assign anIcon(and probably aPotentialIcon) before the item participates in any UI. -
Centralize icon updates. With the SDK, the
propertyChangedcallback is the natural place to callupdateIcon. Without the SDK, call your icon-update routine from every mutator. -
setPropertytriggerspropertyChangedautomatically. Don't call your update logic directly from your mutators if you're already going throughsetProperty— that double-fires. - Keep saved tables simple and serializable. Stick to plain values, strings, booleans, numbers, and nested tables of those.
-
saveandloadshould be symmetric. Fields you save should be read in load, with sensible defaults for missing fields (since old save files might not have them). -
providesCodereturns a count, not a boolean. Return0for "doesn't provide",>= 1for "provides this many". -
Be defensive in
load. Checkdataforniland missing fields — older save files may not have everything your current item expects. -
Don't
printfrompropertyChanged. It fires constantly and will flood the script console.
- Authoring Lua Scripts — top-level overview
- Authoring Items — built-in item types you should prefer when they fit
- SDK custom_item folder — the source files to copy into your pack
- Authoring Lua — API Reference → ScriptHost:CreateLuaItem — the underlying factory call
-
Authoring Lua — API Reference → ImageReference — for constructing the
IconandPotentialIconvalues -
Authoring — Image Filters — the filter spec used when constructing image references (the SDK example uses
@disabled)
- Installation
- Installing and Loading Packages
- Item Types and Mouse Controls
- Map Locations
- Map Location Colors
- Saving and Loading
- Multi-Tab and Window
- Autotracking
- NDI Broadcasting
- Twitch Chat HUD
- Note Taking
- Voice Control
- Keyboard Shortcuts