Any object on a game table can carry a Lua script that reacts to game events and queries or manipulates the table. Scripts run on the server — the authority over the world — so their effects are identical for every player. Open the Script item in an object's context menu to edit it; edits hot-reload live.
Names here often match what other virtual tabletops call the same idea, so the API reads as familiar if you have scripted one before. It is still its own surface: check the reference pages rather than assuming a function exists because you have seen it elsewhere.
This section:
- Events and veto hooks — everything a script can react to or cancel.
- Objects reference — every method on the object handle.
- World reference —
tw, zones, snap points, storage, music, timers. - Players and turns — the player handle and the Turns system.
- Custom UI — ribbon tabs, panels, menus, hotkeys.
- Recipes — complete worked examples.
The model
- Each scripted object runs in its own sandboxed Lua state: one script cannot read another's locals, and a script that loops or eats memory is disabled without affecting the server or other scripts.
selfis the object the script is attached to.- Scripts coordinate through shared data (tags,
setVar/setTable,tw.store) and cross-script calls (obj:call("fn", ...)), not shared globals. - Scripts persist: per-object scripts are stored with the table (keyed by the object's guid), and travel inside snapshots, saved tables, exports and library templates.
-- a click counter
function onLoad()
self:setVar("clicks", self:getVar("clicks") or 0)
end
function onClick(player)
local n = self:getVar("clicks") + 1
self:setVar("clicks", n)
print("clicked", n, "times, last by player", player)
end
Registering handlers
Two equivalent styles — define a global function with the event's on-name, or register explicitly:
function onUpdate(dt) end -- the global-function style
self:on("update", function(dt) end) -- explicit registration
Both work for every event in the events reference. An unknown event name in self:on is silently ignored — check spelling against the reference.
The Global script
Besides per-object scripts there is one table-level script with no self. Open it from the ribbon (Table → Script). It receives table-wide events — player connect/disconnect, turn changes, the trySpawn and onPlayerAction vetoes — and is the natural home for game rules, table UI and zone logic. It persists in the table's database and survives restarts.
function onPlayerConnect(player)
local p = tw.getPlayer(player)
tw.broadcastToAll((p and p:getName() or "Someone") .. " joined")
end
A game can also ship a Global script ("globalScript" in game.json); a user's saved edit overrides it. The application's own base UI is implemented the same way in a privileged system script that loads before yours and cannot be edited over the wire — which is why your Global script's contributions never collide with the base UI's.
Persisting state
Three tiers, all of which survive snapshots, saves and Time Machine rollback:
self:setVar(key, primitive)— per-object primitives (number/string/boolean).self:setTable(key, tbl)/tw.setTable(key, tbl)— whole tables, per-object or table-wide, stored by value.tw.store(key, value)— table-wide key/value in the database (tables round-trip through JSON).
A bare Lua global (count = 5) or a closure upvalue does not round-trip: a live VM can't be frozen, so on restore the source is re-run to rebuild handlers and the saved vars are re-injected.
onSave / onLoad
For state that doesn't fit the typed stores, return a string from onSave(); it is captured with the object and handed back to onLoad(saved) on restore.
function onSave()
return JSON.encode({ score = self:getVar("score") or 0 })
end
function onLoad(saved)
if saved ~= "" then self:setVar("score", JSON.decode(saved).score) end
end
Defining onSave opts the script into this model: its onLoad(saved) then runs on every restore. A script without onSave keeps the simpler behaviour — vars are re-injected and onLoad does not re-run on a restore, so one-shot setup isn't repeated. Two things to know:
onSavefires on every automatic snapshot — continuously during play, not only on explicit saves. Keep it fast and side-effect free.- Ephemeral contributions — zones, snap points, UI, hotkeys, menu items, timers — are not part of the data snapshot. Declare them in
onLoadso they come back after a reload or restore.
Errors and hot reload
Saving a script compiles it in a fresh state; on a compile error the old version keeps running and the error appears in the editor. At runtime, any uncaught error (including out-of-memory and the time budget) prints to the browser console and disables the script until you save it again. A veto hook that errors allows the action — a broken script can never lock the table up.
On reload, the script's previous contributions are cleared: zones, ribbon items, panels, menus, hotkeys, hidden-UI entries and pending timers/dialog callbacks all die with the old instance.
Sandbox and limits
- Stdlib:
base,math,string,tableonly — no file, OS or network access;load/require/rawset/rawget/collectgarbageare removed. - Memory: 2 MB per script state; exceeding it is an error (script disabled).
- CPU: each handler call has a 50 ms wall-clock budget; an infinite loop is aborted, the server never freezes. Script-to-script
callchains share the outer deadline and may nest 64 deep. - Log flood: ~40 log lines per handler call; beyond that output is suppressed for the rest of the call.
- Contribution caps (per script): 512 vars and 512 tables per object, 256 hotkeys, 256 menu items, 256 panels, 64 ribbon tabs with 256 items, 1024 zones table-wide. Over-cap writes are dropped silently.
Not available (yet)
So that neither you nor your AI assistant has to guess — the following do not exist in the scripting API:
- Networking: no HTTP requests, no
WebRequest, no sockets. - Modules: no
requireand no shared libraries between scripts; reuse code viaobj:callor by keeping data intw.setTable. - Spawning assets by URL: scripts spawn built-in kinds (
tw.spawnObject), library templates (tw.spawnTemplate) and serialized snapshots (tw.spawnObjectJSON) — there is no fetching of models or images from the web. - Timers across reloads:
Waittimers die when their script reloads — re-arm them inonLoad. - Precise physics contacts: collision events are bounding-box overlaps, not mesh-accurate contact points (
tw.castrays are collider-accurate). - Per-player Lua: scripts run on the server only; per-player behaviour is expressed through the player handle (dialogs, visibility, tools), not client-side code.