Skip to content

Luau Scripting

Setonix Luau plugins now get a high-level Setonix runtime object that wraps the low-level bridge. The goal is to move everyday scripting away from raw JSON-shaped events and toward wrapped players, tables, cells, commands, and generic utility helpers.

The raw bridge is published as SetonixRaw and is also available through Setonix.raw when you need direct access, but the old standalone State, Events, and Server globals are no longer part of the public scripting surface.

Put source in scripts/<filename>.lua or .luau inside a pack, then reference that exact filename from a mode’s script field. Install the pack and create a game with that mode to run it. See game modes and scripts for the complete setup.

Luau runs on the local host in singleplayer and app-hosted games, and on the dedicated server in multiplayer. Native and web builds share the runtime API; self-hosted web builds must include the browser runtime. Additional dedicated-server scripts can be placed in its scripts/ directory with the .lua extension.

The type declarations complement this guide. Blackjack is a complete built-in example.

local deck = Setonix.cells.get(0, 0)
local stack = Setonix.cells.get(0, 1)
Setonix.events.on("player_join", function(event)
event.player:sendMessage("Welcome to " .. Setonix.namespace())
event:setToolbar({
editable = false,
actions = {
Setonix.server.toolbarAction("blackjack_hit", "Hit"),
Setonix.server.toolbarAction("blackjack_stand", "Stand"),
},
})
end)
Setonix.commands.register("stand", function(event)
event:reply("Standing with current hand.")
event:cancel()
end)
Setonix.events.action("blackjack_hit", function(event)
deck:moveTopTo(stack, 1)
end)

You can use either the flat helpers or the namespaced variants:

  • Setonix.on(...) or Setonix.events.on(...)
  • Setonix.command(...) or Setonix.commands.register(...)
  • Setonix.player(id) or Setonix.players.get(id)
  • Setonix.players() or Setonix.players.all()
  • Setonix.currentTable() or Setonix.tables.current()
  • Setonix.getTable(name) or Setonix.tables.get(name)
  • Setonix.cell(...) or Setonix.cells.get(...)
  • Setonix.process(...) or Setonix.requests.*(...)
  • Setonix.send(...) or Setonix.server.*(...)
  • Setonix.storage.get(...), Setonix.storage.set(...), and Setonix.storage.update(...)

The top-level helpers are convenient for short scripts. The namespaced entry points are better once a script grows beyond a few callbacks.

Setonix.events.on("message", function(event)
Setonix.log("chat from " .. tostring(event.source) .. ": " .. event.message)
end)
Setonix.events.action("blackjack_stand", function(event)
if event:isFrom(event.player) then
event:reply("You pressed stand.")
end
end)

world_saving runs before Setonix writes the world file. Setonix waits for the handler, so this is the best place to collect derived Lua state and flush it into Setonix.storage.

Setonix.events.on("world_saving", function()
Setonix.storage.set("round", {
currentPlayer = CurrentPlayer,
playerScores = PlayerScores,
})
end)

world_closing runs when the world is being closed or scripts are being reloaded. Setonix waits for the handler before disposing the plugin, so this is the right place to clean up resources or write final state that only matters on shutdown.

Setonix.events.on("world_closing", function()
Setonix.storage.set("closedCleanly", true)
end)

Supported aliases include:

  • player_join, player_joined, user_join, user_joined
  • world_saving, saving
  • world_closing, closing
  • message, chat
  • toolbar_action, action
  • objects_moved, objects_spawned, objects_removed
  • cell_hide_changed, cell_hidden
  • many direct snake_case aliases for client, hybrid, and server event names from the Setonix event model

Every high-level event includes:

  • event.raw: the original raw event table
  • event.details: the low-level event details userdata
  • event.player: wrapped player when available
  • event.cell, event.from, event.to: wrapped cells when present on the raw event
  • event.tableRef: wrapped table for table-bound events
  • event.action: convenience alias for actionId
  • event.cancelled: read/write cancellation state

Methods:

  • event:cancel()
  • event:isCancelled()
  • event:schedule(playableEvent, target)
  • event:reply(message, author)
  • event:broadcast(message, author)
  • event:setToolbar(toolbar)
  • event:getPlayer()
  • event:getCell()
  • event:isFrom(playerOrId)
  • event:table()

Setonix.command(name, handler) and Setonix.commands.register(name, handler) create simple chat commands for both !name and /name by default.

Setonix.commands.register("deal", function(event)
event:reply("Dealing a card...")
event:cancel()
end)

Inside the handler:

  • event.command contains the matched command name
  • event.arguments contains whitespace-split arguments

user_left (also player_left, player_leave, and user_leave) runs when a player disconnects. event.player.id and event.source identify the departing player. This lifecycle notification does not contain a board update to cancel or schedule; send any cleanup updates directly through the server helpers.

Setonix.channels.authority is the local host channel and Setonix.channels.all is the broadcast target. Setonix.players.all() includes the local player in offline and peer-hosted games.

local player = Setonix.players.get(7)
if player:isOnline() then
player:sendMessage("Your turn")
end

Player fields and methods:

  • player.id
  • player:sendMessage(message, author)
  • player:setToolbar(toolbar)
  • player:isOnline()
  • player:equals(playerOrId)

Tables and cells are live views into the current state. They read from the underlying world state whenever queried.

local tableRef = Setonix.tables.current()
local discard = tableRef:cell(4, 0)
if discard:hasObjects() then
discard:clear()
end
tableRef:spawnObject({ x = 0, y = 0 }, Setonix.namespace() .. ":cards", {
variation = "heart-ace",
hidden = true,
})

Table methods:

  • tableRef.name
  • tableRef:raw()
  • tableRef:cell(positionOrX, y)
  • tableRef:getCell(positionOrX, y)
  • tableRef:cells()
  • tableRef:position(x, y)
  • tableRef:bounds() — returns minimum and maximum positions, or two nil values for an unrestricted table
  • tableRef:contains(positionOrX, y) — checks the inclusive bounds and table name
  • tableRef:setBounds(minimum, maximum, target) — restricts the canvas to two integer cell coordinates
  • tableRef:clearBounds(target) — removes the restriction
  • tableRef:spawn(positionOrX, y, objects, target)
  • tableRef:spawnObject(positionOrX, y, asset, options, target)
local board = Setonix.tables.current()
board:setBounds({ x = 0, y = 0 }, { x = 5, y = 3 })
board:cell(0, 1):layout({
type = "DistributeCellMergeStrategy",
direction = "horizontal",
maxCards = 52,
}, 6)
board:cell(0, 1):setObjects({}) -- replace the entire hand, including clearing it

Bounds include both corners and accept reversed corners. Both corners must be supplied together; use clearBounds() to return to the sandbox. The client limits grid rendering and camera movement to the configured table. Restrictions are saved with the world. Restore previous bounds and layouts in world_closing if they belong only to your mode.

Outbound board updates are queued in order. Keep authoritative game progress in Lua state rather than reading a cell immediately after sending its update. This also makes it possible to restore the same round from persistent storage.

State helpers:

  • Setonix.state.raw()
  • Setonix.state.tableName()
  • Setonix.state.tables()
  • Setonix.state.info()
  • Setonix.state.players()
  • Setonix.state.teamMembers()
  • Setonix.state.currentTable()

Each Luau plugin gets a private JSON-backed storage object saved with the world. Missing values and optional JSON null fields are represented as nil in Luau; Setonix.storage.get(key, default) returns the default for a missing value. Values must be JSON-compatible: strings, numbers, booleans, arrays, and plain tables.

local visits = Setonix.storage.update("visits", function(value)
return (value or 0) + 1
end, 0)
Setonix.log("This script has seen " .. tostring(visits) .. " loads.")

Storage helpers:

  • Setonix.storage.get(key, default)
  • Setonix.storage.set(key, value)
  • Setonix.storage.update(key, updater, default)
  • Setonix.storage.remove(key)
  • Setonix.storage.clear()
  • Setonix.storage.all()

Use storage for Lua-local game progress that must survive closing and reopening a world, such as the current player, round phase, score tables, or shuffled order references. Board objects are saved with the world, but plain Lua variables are recreated when the script VM starts again.

Cell methods:

  • cell.table, cell.x, cell.y
  • cell:key()
  • cell:toGlobal()
  • cell:copy()
  • cell:withTable(name)
  • cell:equals(positionOrCell)
  • cell:raw()
  • cell:objects()
  • cell:tiles()
  • cell:isEmpty()
  • cell:topObject()
  • cell:object(index)
  • cell:topObjects(count)
  • cell:countObjects()
  • cell:hasObjects()
  • cell:spawn(objects, target)
  • cell:setObjects(objects, target) — replaces the cell’s complete object list
  • cell:layout(strategy, span, target) — sets a merge layout; use nil to remove it
  • cell:spawnObject(asset, options, target)
  • cell:remove(objects, target)
  • cell:clear(target)
  • cell:show(object, target)
  • cell:hide(object, target)
  • cell:moveTo(targetCell, objects, target)
  • cell:moveTopTo(targetCell, count, target)
  • cell:shuffle(force)
  • cell:roll(object, force)

The Setonix.collections and Setonix.scoring namespaces are intentionally generic so game-mode scripts can build their own domain logic without the API assuming a particular pack structure.

local order = Setonix.collections.shuffle({ "a", "b", "c" })
local total = Setonix.scoring.bestTotal(items, function(item)
return item.values
end, 21)

Available helpers:

  • Setonix.collections.copy(values) — copies a sequence and preserves its JSON array type, even when empty
  • Setonix.collections.sorted(values, comparator) — returns a sorted copy without changing the input
  • Setonix.collections.shuffle(values)
  • Setonix.scoring.bestTotal(items, resolverOrMap, maxScore)

The server namespace collects common outbound actions:

Setonix.server.broadcast("Round started")
Setonix.server.setToolbar({
editable = false,
actions = {
Setonix.server.toolbarAction("draw", "Draw"),
},
}, event.source)

Available helpers:

  • Setonix.server.send(event, target)
  • Setonix.server.process(event, force)
  • Setonix.server.message(message, target, author)
  • Setonix.server.broadcast(message, author)
  • Setonix.server.setToolbar(toolbar, target)
  • Setonix.server.toolbarAction(id, label, enabled, showInToolbar)
  • Setonix.server.events.tableBoundsChanged(tableName, minimum, maximum)
  • Setonix.server.events.objectsChanged(cell, objects)
  • Setonix.server.events.mergeStrategyChanged(cell, strategy, span)
  • Setonix.server.events.* builders for hybrid and server events such as objectsMoved, objectsRemoved, cellHideChanged, message, toolbarUpdated, dialogsClosed, and more

The flat aliases Setonix.send, Setonix.process, Setonix.message, Setonix.broadcast, Setonix.setToolbar, and Setonix.toolbarAction remain available too.

Notes support Markdown links to other notes and buttons for game actions:

[Read the rules](note:Rules)
[Open turn guide](note:Turn%20guide)
[Draw a card](action:draw)

A note: link opens the named note in the current reader. Encode spaces as %20. Set a note’s Home page property in the editor to choose the default note for the game.

An action: link renders a Material 3 button. Its identifier must match an action in the current player’s toolbar, and it stays disabled if that action is missing or disabled. Clicking sends the same ToolbarActionRequest as a toolbar button, so scripts must enforce turn and permission checks in their action handler.

Use showInToolbar = false to expose an action only inside notes:

Setonix.player(id):setToolbar({
editable = false,
actions = {
Setonix.server.toolbarAction("draw", "Draw", isPlayersTurn),
Setonix.server.toolbarAction("switch_rules", "Switch rules", isHost, false),
},
})
Setonix.notes.set("Rules", "[Switch rules](action:switch_rules)")

Both enabled and showInToolbar default to true. Note action buttons remain usable when editable is false; that flag disables sandbox editing and note mutations.

Client-style requests now have a high-level namespace too, so scripts no longer need to build request tables by hand.

Examples:

  • Setonix.requests.teamJoin(team, force)
  • Setonix.requests.teamLeave(team, force)
  • Setonix.requests.cellRoll(cell, object, force)
  • Setonix.requests.shuffleCell(cell, force)
  • Setonix.requests.packsChange(packs, force)
  • Setonix.requests.boardsSpawn(tableName, assets, force)
  • Setonix.requests.boardRemove(cell, index, force)
  • Setonix.requests.boardMove(tableName, fromCell, toCell, index, force)
  • Setonix.requests.dialogClose(id, value, force)
  • Setonix.requests.images(ids, force)
  • Setonix.requests.modeChange(location, force)
  • Setonix.requests.modePlain(force)
  • Setonix.requests.authenticate(signature, publicKey, force)
  • Setonix.requests.toolbarAction(actionId, force)
local hiddenAce = Setonix.object(Setonix.namespace() .. ":cards", {
variation = "spade-ace",
hidden = true,
})

Use Setonix.object(asset, options) to create spawnable object payloads without manually building raw tables.

The low-level bridge is still available when needed:

SetonixRaw.Server:Send({
type = "MessageSent",
user = 0,
message = "raw access still works",
}, nil)

Raw access points:

  • SetonixRaw.Events
  • SetonixRaw.State
  • SetonixRaw.Server
  • SetonixRaw.events
  • SetonixRaw.state
  • SetonixRaw.server
  • Setonix.raw.Events
  • Setonix.raw.State
  • Setonix.raw.Server
  • Setonix.raw.events
  • Setonix.raw.state
  • Setonix.raw.server

Notes are shared Markdown documents saved with the world. Players can follow a note beside the board while scripts update it. Use them for scoreboards, turn reminders, or rules:

Setonix.notes.set("Scoreboard", "# Round 1\n\n**Player 1** is playing.")
Setonix.notes.remove("Scoreboard")

Both methods accept an optional target channel. The standard event builders are also available as Setonix.server.events.noteChanged(name, content) and noteRemoved(name). When a script manages a note, it can cancel note_changed and note_removed requests for that name. Keep secret information out of shared notes.

For card layouts, fillVariableSpace = false keeps cards centered with spacing of at most one cell. Longer hands compress to fit their merged span; reverse reverses either layout.