# Getting started

## What is TTCraft

TTCraft (TableTopCraft) is a platform for tabletop RPG and board-game communities.
Life on TTCraft happens in **rooms**: each room belongs to a community and holds its
conversations, its games and its people. Inside rooms you will find **channels** for
talking and **game tables** for playing; next to all of that live **modules** — game
content published by other users — and **direct messages** for private conversations.

## Create an account

TTCraft has no passwords. Signing up and signing in both work with a one-time code
sent to your email:

1. Open **Sign up** and enter your email address and a name.
2. A short one-time code arrives at that address.
3. Type the code in — the address is confirmed and you are in.

Signing in later is the same two steps: your email, then the code from the message.
If the message does not arrive, check the spam folder and request a new code — each
code works once and stays valid for only a few minutes.

## Rooms and channels

A **room** is a community's space: a game group, a club, a circle of friends. Every
room is organised into **channels**, and a channel comes in one of two kinds:

- **Text channels** — ordinary live chat: messages, replies, reactions.
- **Post channels** — long-form posts for announcements, session recaps and
  campaign notes, with comments under each post.

### Game tables

A **game table** opens inside a channel. The table is where the actual play
happens: members of that channel sit down at it and play together without leaving
TTCraft. When the session is over, the table can be closed and the channel goes
back to being a regular conversation.

Tables are programmable: any object on a table can carry a Lua script, and the
table itself has a Global script for house rules and custom UI — see
[Table scripting](/docs/scripting).

## Modules

**Modules** are game content published by TTCraft users: adventures, rule variants,
material for your table. Browse the module catalogue, open any module to read it,
and publish your own when you have something to share with other game masters and
players.

## Direct messages

Not everything belongs in a room. **Direct messages** are private one-on-one
conversations — write directly to anyone you play with.

## Quick reference

| Term | What it is |
| --- | --- |
| Room | A community space that holds channels, tables and members |
| Channel | A text chat or a post feed inside a room |
| Game table | A play space opened inside a channel |
| Module | Game content published by a user |
| Direct message | A private conversation between two users |

---

# Table scripting

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](/docs/scripting-events) — everything a script can react to or cancel.
- [Objects reference](/docs/scripting-objects) — every method on the object handle.
- [World reference](/docs/scripting-world) — `tw`, zones, snap points, storage, music, timers.
- [Players and turns](/docs/scripting-players) — the player handle and the Turns system.
- [Custom UI](/docs/scripting-ui) — ribbon tabs, panels, menus, hotkeys.
- [Recipes](/docs/scripting-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.
- `self` is 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.

```lua
-- 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:

```lua
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](/docs/scripting-events). 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.

```lua
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:

1. **`self:setVar(key, primitive)`** — per-object primitives (number/string/boolean).
2. **`self:setTable(key, tbl)` / `tw.setTable(key, tbl)`** — whole tables, per-object or table-wide, stored by value.
3. **`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.

```lua
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:

- `onSave` fires 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 `onLoad` so 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`, `table` only — no file, OS or network access; `load`/`require`/`rawset`/`rawget`/`collectgarbage` are 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 `call` chains 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 `require` and no shared libraries between scripts; reuse code via `obj:call` or by keeping data in `tw.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:** `Wait` timers die when their script reloads — re-arm them in `onLoad`.
- **Precise physics contacts:** collision events are bounding-box overlaps, not mesh-accurate contact points (`tw.cast` rays *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.

---

# Events and veto hooks

Handlers are registered by defining a global function with the event's `on`-name, or with `self:on("event", fn)` — see [registering handlers](/docs/scripting#registering-handlers). In every table below, `player` is a numeric player id, and `0` means the server itself triggered the action.

Events are **observational**: they fire *after* the action happened. [Veto hooks](#veto-hooks) (`try*`) run *before* it and can cancel it.

## Object events

| Handler | Fires |
| --- | --- |
| `onUpdate(dt)` | every server frame (~60 Hz); `dt` in seconds |
| `onFixedUpdate(dt)` | immediately after `onUpdate`, same cadence |
| `onClick(player)` | a tap — press and release without dragging |
| `onDrag(player)` (alias `onPickUp`) | the object is grabbed; fires on each member of a carried stack or group |
| `onDrop(player)` | the object is released (after drop-snapping and hand capture) |
| `onLoad(saved)` | the script (re)compiles (`saved` is `""`), or is restored from a snapshot **if it defines `onSave`** (`saved` is the last saved string) — see [persistence](/docs/scripting#onsave--onload) |
| `onSave()` → string | state capture: every automatic snapshot, table save and export — fires continuously during play |
| `onFlip(player)` | the object is flipped over |
| `onRotate(player)` | rotated or aligned (align / rotate left / right / 180°) |
| `onSetpose(player)` | position/rotation/scale set via the gizmo |
| `onFlick(player)` | the object is flicked / thrown by a player |
| `onHover(player)` | a player's cursor comes to rest over the object (enter only) |
| `onPeek(player)` | a player peeks at the object (Alt-hover a card) |
| `onNumberTyped(player, number, alt)` | a player types a number while hovering the object |
| `onCollisionEnter(other)` | another object starts overlapping (axis-aligned bounding boxes, tested per frame only for objects with a collision handler) |
| `onCollisionStay(other)` | keeps overlapping, each frame |
| `onCollisionExit(other)` | stops overlapping |
| `onSpawn(player)` | this object was spawned by a player (spawn command / library template), after its script is live. Not fired for paste, container takes, or script spawns |
| `onDestroy(player)` | the object is deleted by a player (delete command / cut). **Not** fired by script `destroy()`, container puts, or Time Machine rollback |
| `onLock(player)` / `onUnlock(player)` | the object is locked / unlocked |
| `onGroup(player)` / `onUngroup(player)` | the object is welded into / released from a rigid group (fires on every member) |
| `onNoteEdit(player)` | this note's text was edited |
| `onRandomize(player)` | the object is tossed (R key) — any kind, not just dice |
| `onStateChange(oldId)` | a **player** switches the multistate; script `setState` doesn't re-fire it |
| `onCounterChange(value, delta, player)` | this object's number moved — a counter object's ±/reset or any object's counter mixin; `value` is the new number, `delta` the signed change |

### Deck, card and container events

| Handler | Fires on | When |
| --- | --- | --- |
| `onShuffle(player)` | the deck / container | it is shuffled (including zone-randomize) |
| `onDraw(card, player)` | the deck | a card is drawn or revealed from it (`card` = the new card). A physical *pull* from the bottom fires only `tryDraw`, not `onDraw` |
| `onMerge(player)` | the **surviving** stack | another stack was merged onto it |
| `onEnterHand(player)` | the card | it enters a player's hand; `player` is the hand's **owner** — a GM can fill someone else's hand |
| `onLeaveHand(player)` | the card | it is played out of the hand with the un-hand action. Simply grabbing your own hand card out does not fire it |
| `onObjectEnterContainer(obj, player)` | the container / deck | `obj` was put or merged into it |
| `onObjectLeaveContainer(obj, player)` | the container / deck | `obj` was taken, drawn or tipped out of it |

### Zone events

Zone events fire on the script that **created** the zone (object script or Global):

| Handler | Fires |
| --- | --- |
| `onObjectEnterZone(zone, obj)` | an object's centre enters the zone |
| `onObjectLeaveZone(zone, obj)` | it leaves |
| `onGroupSort(zone, objects)` → list | a layout zone with `sort = "custom"` asks you to reorder; return the reordered list |

## Veto hooks

Veto hooks run *before* an action. **Return `false` to cancel it.** Any other return (`true`, `nil`, nothing), no handler, or a handler error allows the action — a broken script never locks the table (the error still disables the script).

Per-object hooks:

| Hook | Cancels |
| --- | --- |
| `tryGrab(player)` | picking the object up (asked per carried stack/group member) |
| `tryFlip(player)` | flipping it |
| `tryFlick(player)` | flicking it |
| `tryRotate(player)` | rotating / aligning it |
| `trySetpose(player)` | setting pose/scale via the gizmo |
| `tryDraw(player)` | drawing, revealing, pulling or taking from this deck/container (one question: "may this player take from this") |
| `tryEnterHand(player)` | taking this card into a hand (`player` = the prospective owner) |
| `tryMerge(other, player)` | merging `other` onto this stack (fires on the target) |
| `tryEnterContainer(other, player)` | putting `other` into this container (fires on the container) |
| `tryRemove(player)` | a player deleting the object (script `destroy()` and rollback bypass it) |
| `tryStateChange(player)` | a player switching the multistate |

```lua
-- only the owner may pick this up, and never while it's locked
function tryGrab(player)
  if self:isLocked() then return false end
  return tw.getPlayer(player):can("play")
end
```

## Global script events

The [Global script](/docs/scripting#the-global-script) has no `self`; it receives table-wide events:

| Handler | Fires |
| --- | --- |
| `onLoad()` | the Global script (re)compiles, and once at server start |
| `onUpdate(dt)` / `onFixedUpdate(dt)` | every server frame |
| `onPlayerConnect(player)` | a player joins (after their state sync) |
| `onPlayerDisconnect(player)` | a player leaves (still in the roster when it fires) |
| `onPlayerChangeColor(player)` | a player's seat color changed |
| `onPlayerChangeCaps(player)` | a player's capabilities changed |
| `onPlayerTurn(current, previous)` | the turn moved (`previous` is 0 for the first turn) — see [Turns](/docs/scripting-players#turns) |

Plus zone events for zones it owns, and its UI/hotkey/menu/dialog callbacks.

### Global veto hooks

| Hook | Cancels |
| --- | --- |
| `trySpawn(params, player)` | a player spawning objects. `params` = `{kind, dieType, template, x, y, z}`; one spawned *set* is vetoed as one action |
| `onPlayerAction(player, action, target)` | **any** capability-gated player action. `action` is the command name; `target` is the object handle, or nil |

```lua
-- table rules: at most 6 dice, and nobody acts out of turn
function trySpawn(params, player)
  if params.kind == "die" and #tw.getObjectsWithTag("die") >= 6 then
    tw.getPlayer(player):print("Too many dice already.")
    return false
  end
end
function onPlayerAction(player, action, target)
  if Turns.isEnabled() and player ~= Turns.current() then return false end
end
```

`onPlayerAction` runs before the matching object-level `try*` hook. The `action` strings are the wire command names; the gameplay ones you'll typically gate are:

`spawn, grab, release, throw, toss, flick, flip, align, rotl, rotr, rot180, setpose, lock, remove, group, ungroup, clipcut, clippaste, click, shuffle, cut, split, draw, reveal, pull, merge, gather, spread, hand, unhand, state, counteradd, countersub, counterreset, counterset, clockstart, clockreset, clockadd, clocksub, clockmode, notetext, setprops, setscript, music, lighting, savetable, resettable`

Not consulted for: the per-frame `move` while dragging, editing the table script itself (`setglobalscript` — an over-eager veto can never lock you out of fixing it), passive viewing (`peek`, `view`, previews), dialog replies, and the table-editor tooling (zones, drawings, snap points, decals and similar). Muted players and out-of-turn players (with turn interactions disabled) are already frozen before your veto runs.

## Ordering and identity notes

- Per frame, each script runs its timers, then `onUpdate`, then `onFixedUpdate`, then collision events; zones are processed after all scripts.
- The Global script is processed first each frame; the system script last.
- `onObjectMenu` is reserved for the privileged system script that builds the base right-click menu — defining it in your scripts has no effect. Add entries with `obj:addContextMenuItem` instead.

---

# Objects reference

`self` in an object script, and anything returned by `tw.getObject`, `tw.getObjectFromGUID`, `objectBelow()` and similar calls, is an **object handle**. All methods are colon-called (`obj:getPosition()`).

Conventions used below:

- Positions are in **metres**; the table surface is `y = 0`.
- Rotations are Euler **degrees**, YXZ order.
- Vectors returned by the API are `{x=, y=, z=}` tables with full [Vector math](/docs/scripting-world#vector).
- Setters accept `(x, y, z)` numbers or a `{x=, y=, z=}` table. A partial table (`{y = 0.5}`) patches only the given components of pose and scale setters.
- Where a method takes "an object", a handle, a numeric id or a guid string all work.

## Object kinds

`obj:kind()` returns one of:

| Kind | What it is |
| --- | --- |
| `die` | a die (d6 by default; other face counts exist as templates) |
| `chip` | a poker-style chip |
| `coin` | a two-sided coin |
| `deck` | a stack of cards (a container) |
| `card` | a single card |
| `prop` | a generic object (model, figure, token) |
| `tablet` | a tablet showing a UI document |
| `counter` | a numeric counter |
| `clock` | a stopwatch / countdown timer |
| `note` | an editable text note |

A dead handle (its object was removed) returns `""`.

## Identity and metadata

```lua
obj:id()              -- stable numeric id
obj:guid()            -- string id / alias; defaults to the numeric id
obj:setGuid("deck1")  -- set a custom string id (must be unique)
obj:kind()            -- kind string, see above
obj:getName()         / obj:setName("King")          -- display name ("" default)
obj:getDescription()  / obj:setDescription("...")    -- public blurb
obj:getGMNotes()      / obj:setGMNotes("...")        -- GM-only notes
```

Name, description and GM notes persist with the table snapshot, like tags and vars.

### Per-player visibility

The server filters the broadcast per client, so hiding is authoritative — a hidden object's data never reaches that player's browser.

```lua
obj:setInvisibleTo({2, 3})  -- players 2 and 3 don't see the object at all
obj:setHiddenFrom({2})      -- player 2 sees only an anonymous grey shape
obj:getInvisibleTo()        -- current player-id list; setInvisibleTo({}) clears
obj:getHiddenFrom()
```

## Transform

```lua
obj:getPosition()           -- Vector (alias obj:position())
obj:setPosition(x, y, z)    -- teleport; also accepts {x=, y=, z=}
obj:translate(dx, dy, dz)   -- move by a delta
obj:getRotation()           -- Vector of Euler degrees
obj:setRotation(x, y, z)    / obj:rotate(dx, dy, dz)
obj:getScale()              / obj:setScale(x, y, z)   -- collider + visual scale
obj:getBounds()             -- { center = Vector, size = Vector } (AABB)
obj:getTransformForward()   -- unit basis vectors of the current rotation
obj:getTransformUp()        / obj:getTransformRight()
obj:localToWorld(v)         -- local point -> world (scale, rotation, position applied)
obj:worldToLocal(v)         -- inverse
```

### Smooth movement

Server-side eased glides — use these for animated dealing, tidying or returning pieces:

```lua
obj:setPositionSmooth(pos)            -- glide to pos; (pos, collide, fast) optional flags
obj:setRotationSmooth(rot)            -- eased rotation to Euler degrees
obj:getPositionSmooth()               -- the glide's target Vector, or nil when not gliding
obj:isSmoothMoving()                  -- true while a glide is in progress
```

## Physics

```lua
obj:getVelocity()         / obj:setVelocity(v)
obj:getAngularVelocity()  / obj:setAngularVelocity(w)
obj:addForce(x, y, z)     -- adds to linear velocity (an impulse-style nudge)
obj:addTorque(x, y, z)    -- adds to angular velocity
obj:getUseGravity()       / obj:setUseGravity(false)  -- float in place, still collidable
obj.use_gravity = false   -- field alias for the same toggle
```

### Joints

```lua
obj:jointTo(other, { type = "point" })   -- ball joint at the midpoint (default)
obj:jointTo(other, { type = "fixed" })   -- rigid link
obj:jointTo(other, { type = "hinge", axis = {x=0, y=1, z=0}, point = {x=0, y=0.1, z=0} })
obj:jointTo(other, { type = "spring", min = 0.05, max = 0.3, frequency = 2, damping = 0.5 })
obj:jointTo()             -- no argument: remove ALL of this object's joints
obj:removeJoints()        -- same
obj:getJoints()           -- { {object = handle, type = "hinge"}, ... }
```

For a rigid **weld** of several objects into one assembly, see `tw.group` in the [world reference](/docs/scripting-world#groups-copy-and-paste); `obj:ungroup()` dissolves the welded group this object belongs to.

## Actions and lifecycle

```lua
obj:flip()                -- animated turn-over
obj:roll()                -- toss with an upward impulse and spin (works on any object)
obj:randomize()           -- deck: shuffle; bag: shuffle entries; else: physical toss
obj:setLock(true)         / obj:isLocked()   -- freeze in place
obj:reset()               -- return to the home pose (default position/rotation), at rest
obj:clone()               -- duplicate next to it; returns the new handle
obj:reload()              -- respawn in place: full-fidelity copy (skin, script, guid,
                          -- tags, vars all kept), original removed; returns the new handle
obj:destruct()            -- remove from the table (alias obj:destroy())
```

## Values, counters and clocks

`getValue`/`setValue` are polymorphic over the object kind:

```lua
obj:getValue()
-- counter: its number        clock: its seconds
-- die: resting top face 1..N coin: 1 = heads, 2 = tails
-- anything else: the nearest rotation-value entry (below), or nil
obj:setValue(6)
-- counter: set the number    clock: set the seconds
-- else: turn to the orientation mapped to 6 in the rotation values (no-op without one)
```

Counter-only methods:

```lua
counter:increment()   / counter:decrement()   -- value ± 1
counter:clear()                                -- back to 0
```

Clock-only methods:

```lua
clock:startStopwatch()    -- reset to 0, count up, run
clock:startTimer(90)      -- set 90 s, count down, run
clock:pauseStart()        -- toggle running
clock:isRunning()         / clock:isCountingDown()
clock:clear()             -- 0 and stopped
```

`onCounterChange(value, delta, player)` fires on the counter when its number moves — see [Events](/docs/scripting-events).

### Rotation values

Map orientations to values for custom dice, tokens and markers. `rotation` is Euler degrees, accepted as `{x, y, z}` or `{x=, y=, z=}`; `nil`/`{}` clears the map.

```lua
obj:setRotationValues({
  { value = 1,      rotation = {0, 0, 0} },
  { value = 6,      rotation = {0, 0, 180} },
  { value = "edge", rotation = {90, 0, 0} },
})
obj:getRotationValues()   -- list of { value, rotation = Vector }
obj:getRotationValue()    -- value of the entry nearest the current pose (nil if none)
```

## Appearance

```lua
obj:setColorTint("#ff8800")           -- "#rrggbb" or {r, g, b} in 0..1
obj:getColorTint()                    -- current hex, or nil
obj:highlightOn("#00ff00")            -- transient outline
obj:highlightOn("#00ff00", 3)         -- auto-clears after 3 s
obj:highlightOff()
```

### Material (PBR)

`setMaterial` patches only the keys you pass:

```lua
obj:setMaterial({ metalness = 0.9, roughness = 0.2 })
obj:setMaterial({ transmission = 1, ior = 1.5, thickness = 0.02 })  -- refractive glass
obj:getMaterial()   -- current table, or nil
```

Accepted keys: `metalness`, `roughness`, `envMapIntensity`, `clearcoat`, `clearcoatRoughness`, `tint`, `softTint`, `emissive`, `emissiveIntensity`, `transmission`, `ior`, `thickness`, `attenuationColor`, `attenuationDistance`.

### Particle effects and sound

```lua
local fx = obj:createEffect("fire")        -- attach a following particle effect
obj:createEffect("confetti", { persistent = false })
tw.stopEffect(fx)                          -- stop one effect by id
obj:clearEffects()                         -- stop everything attached to this object

obj:playSound("assets/clack.ogg", { volume = 0.8 })  -- one-shot, positioned at the object
```

## Home pose and drag behaviour

The same per-object properties the Properties dialog exposes:

```lua
-- Home pose (captured at spawn): what obj:reset() returns to.
obj:getDefaultPosition()  / obj:setDefaultPosition(x, y, z)
obj:getDefaultRotation()  / obj:setDefaultRotation(x, y, z)

-- Pickup rotation: with rotateOnGrab on, the object snaps to grabRotation when grabbed.
obj:getGrabRotation()     / obj:setGrabRotation(x, y, z)
obj:getRotateOnGrab()     / obj:setRotateOnGrab(true)

-- Stand a toppled figure back up on pickup WITHOUT turning it (heading kept).
-- Default on for things with an "up"; off for cards, decks, dice, coins.
obj:getUprightOnGrab()    / obj:setUprightOnGrab(false)

-- Drag behaviour:
obj:getCollideWhileDragging() / obj:setCollideWhileDragging(true)  -- default false
obj:getLiftOverObjects()      / obj:setLiftOverObjects(false)      -- default true
obj:getLiftHeight()           / obj:setLiftHeight(0.15)            -- metres; -1 = global

-- Surfaces and hands:
obj:getDrawable()   / obj:setDrawable(true)   -- the drawing tool may draw on it
obj:getHandable()   / obj:setHandable(true)   -- hand zones may hold it
```

## Queries

```lua
obj:distanceTo(other)   -- metres (handle, id or guid); -1 if unresolvable
obj:objectBelow()       -- the object it rests on, or nil
obj:objectAbove()       -- the object resting on it, or nil
obj:heldBy()            -- player id holding it, or nil
obj:isFaceDown()        -- cards/decks: boolean; nil for other kinds
obj:getZones()          -- scripting zones currently containing this object
```

## Tags

Tags are shared metadata every script can see; they persist with the snapshot and drive zone filters, snap-point filters and `tw.getObjectsWithTag`.

```lua
obj:addTag("enemy")     / obj:removeTag("enemy")
obj:hasTag("enemy")     -- boolean
obj:getTags()           -- list of strings
obj:setTags({"enemy", "boss"})   -- replace the whole set
obj:hasAnyTag()
obj:hasMatchingTag(other)        -- any tag in common with another object
```

## Shared data and cross-script calls

Vars hold primitives (number / string / boolean; `nil` erases). Tables carry whole nested structures, stored **by value** — `getTable` returns an independent copy each call, so mutate-then-`setTable` to write back. Both are visible to every script and survive snapshots and rollback.

```lua
obj:setVar("hp", 10)          / obj:getVar("hp")
obj:setTable("cfg", { hp = 10, items = {"sword"} })
obj:getTable("cfg")           -- a fresh copy
obj:setTable("cfg", nil)      -- clear
obj:call("fnName", a, b)      -- call a global function in that object's script
```

> **Persistence and time travel.** Snapshots (and the Time Machine rollback) store a script's **data**: source, enabled flag, name/tags, vars and tables. A live Lua VM can't be frozen, so on restore the source re-runs to rebuild handlers and the saved vars are re-injected. Keep state that must survive a rollback in `setVar`/`setTable` (or `onSave`, see the [overview](/docs/scripting#persisting-state)); a bare global or a closure upvalue won't round-trip.

## Serialization

```lua
obj:getJSON()   -- the object's full snapshot as a JSON string (decks include cards)
obj:getData()   -- the same, decoded to a Lua table
```

Round-trips with `tw.spawnObjectJSON` / `tw.spawnObjectData` — clone an object across saves, stash a "shopping catalogue" of spawnables, or template complex pieces.

## Containers: decks, bags and chests

A container is a **property**, not a kind: a deck is one, and any object can be turned into one (bags, chests). The same methods then apply — a bag's entries are whole objects rather than cards.

```lua
box:count()                  -- entries (alias getQuantity)
box:getObjects()             -- deck: { {index, code, name}, ... }
                             -- bag:  { {index, name, kind, guid}, ... }
box:shuffle()                / box:randomize()
box:putObject(obj)           -- deck: merge (returns the surviving stack);
                             -- bag: swallow the object (returns the bag)
box:takeObject()             -- the top entry; returns its handle
box:takeObject({ index = 0 })              -- by getObjects() index (0 = bottom)
box:takeObject({ name = "Ace of Spades" }) -- first entry with that name
box:takeObject({ top = false })            -- the bottom entry
box:takeObject({ flip = true })            -- spawn face up (decks)
box:takeObject({ position = {x=0.2, y=0.1, z=0}, rotation = {0, 90, 0},
                 smooth = true,            -- glide instead of teleporting
                 callback = function(o) o:highlightOn("#00ff00", 1) end })
```

Deck/card specifics:

```lua
deck:deal(n)                     -- deal n cards onto the table
deck:deal(n, player)             -- ...into that player's grip
deck:dealToHand(player, n)       -- draw n cards (default 1) concealed into a hand
deck:dealToColorWithOffset(offset, flip, player)  -- top card at the player's seat
                                 -- + offset; face up if flip; returns the handle
deck:cut(n)                      -- n cards off the top; returns new handles
deck:split(n)                    -- split into n piles; returns new handles
deck:group()                     -- gather the touching pile into one face-down deck
deck:search(player, function(index, code)   -- private search window for that player
  if index then print("picked entry", index, "code", code) end
end)
card:group()                     -- also works from a loose card: collects its pile
```

Card `code` is `1..52`: `(suit * 13) + rank + 1` with suits clubs/diamonds/hearts/spades and ranks ace, 2..10, J, Q, K.

Bag flags:

```lua
bag:setInfinite(true)     / bag:isInfinite()      -- taking hands out copies
bag:setRandomOrder(true)  / bag:isRandomOrder()   -- unaimed takes draw at random
```

An object that enters a bag is serialized whole — skin, physics, script, vars, guid — and comes back out as the same object. Copies from an infinite bag get fresh guids and names. Container events and vetoes (`tryEnterContainer`, `tryDraw`, `onObjectEnterContainer`, `onObjectLeaveContainer`) are listed in [Events](/docs/scripting-events).

## Per-object snap points

Local coordinates — the points move and rotate with the object (board cells on a board, slots on a mat). A dropped object within `range` (metres, XZ) is pulled to the nearest matching point.

```lua
obj:setSnapPoints({
  { position = {x = 0.2, y = 0, z = 0} },                              -- any object
  { position = {x = -0.2, y = 0, z = 0}, rotation = 90, range = 0.08,
    tags = {"card"} },                                                 -- tag-filtered
})
obj:getSnapPoints()      / obj:clearSnapPoints()
```

Table-global snap points live on `tw.setSnapPoints` — see the [world reference](/docs/scripting-world#snap-points).

## Multistate objects

An object can hold several **states** — alternative appearances (label, tint, model, textures, die type, material). Players cycle them from the context menu; scripts drive and react to them.

```lua
obj:setStates({
  { label = "Untapped", tint = "#ffffff" },
  { label = "Tapped",   tint = "#aa8844" },
})
obj:getStates()        -- { {id, label}, ... }
obj:getStateId()       -- active state (1-based; 0 = none)
obj:setState(2)
obj:shuffleStates()    -- switch to a random state
```

`onStateChange(oldId)` fires when a **player** changes the state; a script-driven `setState` doesn't re-fire it. The `tryStateChange` veto can cancel a player's change.

## Per-object menu items and hotkeys

```lua
-- Right-click menu entries on this object, calling its own script functions:
obj:addContextMenuItem("Reset", "reset")
obj:addContextMenuItem({ label = "Burn", onClick = "burn", icon = "remove",
                         category = "danger", keepOpen = false })
obj:addContextMenuItem({ label = "Flip", command = "flip", icon = "flip" })  -- native command
obj:clearContextMenu()

-- Object-scoped hotkey: fires only while THIS object is hovered or focused.
obj:addHotkey("r", "onR", "Roll me")   -- callback onR(player) runs in this object's script
obj:clearHotkeys()
```

The full menu-item and control vocabulary (sliders, submenus, toggles) is in [Custom UI](/docs/scripting-ui). All contributions are cleared when the script reloads — declare them in `onLoad`.

## Tablet elements

A `tablet` object shows a UI document (pages of labels, values, lists). Scripts address elements by their id:

```lua
tab:setElementText("title", "Round 3")
tab:setElementValue("score", 42)      / tab:getElementValue("score")
tab:setElementTooltip("score", "VP")  / tab:getElementTooltip("score")
tab:setListEntries("log", { "Alice: 3", "Bob: 5" })
tab:addListEntry("log", "Carol: 2")
tab:clearListEntries("log")           / tab:getListEntries("log")
```

Spawn a tablet with a document via `tw.spawnTablet` — see the [world reference](/docs/scripting-world#spawning).

---

# World reference

Everything table-wide lives on the `tw` global, with a few sibling globals for subsystems: `Grid`, `Hands`, `Lighting`, `Music`, `Turns`, `Wait`, `JSON`, `Vector`, `Color`. This page covers all of them except the [player handle and Turns](/docs/scripting-players) and the [UI builders](/docs/scripting-ui).

Anywhere a function takes an object you may pass a handle, a numeric id or a guid string.

## Finding objects

```lua
tw.getObject(id)                     -- handle or nil (numeric id)
tw.getObjectFromGUID("deck1")        -- by string id / alias
tw.getObjects()                      -- every object on the table
tw.getObjectsWithTag("die")          -- objects carrying a tag
tw.getObjectsWithAnyTags({"a", "b"}) -- at least one of the tags
tw.getObjectsWithAllTags({"a", "b"}) -- all of the tags
tw.distance(a, b)                    -- metres between two objects; -1 if missing
```

## Spawning

```lua
tw.spawnObject({ type = "die", position = {x=0, y=0.3, z=0} })       -- returns the handle
tw.spawnObject({ type = "card", code = 14, faceUp = true })           -- ace of diamonds
tw.spawnObject({ type = "deck" })                                     -- full 52-card deck
```

`type` is one of `die`, `chip`, `coin`, `deck`, `card`, `counter`, `clock`, `note`, `tablet` (an unknown string falls back to a d6 die). `code` and `faceUp` apply to cards; position defaults to `{0, 0.3, 0}`.

`tw.spawnTemplate` spawns anything the object **library** holds — custom models, skins, filled bags, whole sets — by template id or gallery name, built exactly as the library window builds it (skin, contents, snap points and the template's script included; a `set` template puts its whole pile down and returns the first piece):

```lua
tw.spawnTemplate("goblin", { position = {x=0.3, y=0.2, z=0} })   -- handle or nil
tw.spawnTemplate({ template = "Leather bag" })                    -- by gallery name
```

And anything that exists (or existed) on the table can be respawned from a **snapshot**:

```lua
local json = template:getJSON()                    -- serialize any live object
tw.spawnObjectJSON({ json = json, position = {x=0.3, y=0.2, z=0} })
tw.spawnObjectData({ data = template:getData() })  -- same, from a Lua table
tw.spawnTablet({ position = {x=0, y=0.1, z=0}, document = { ... } })  -- tablet + UI document
tw.destroyObject(obj)
```

Snapshots carry everything: kind, skin, contents, physics properties, script with its vars. Keep a serialized template in `tw.setTable` (or an infinite bag) and stamp out copies at runtime.

## Groups, copy and paste

```lua
tw.group({a, b, c})        -- weld objects into one rigid assembly; returns group id (0 = none)
tw.ungroup(obj)            -- dissolve the group obj belongs to
tw.copy({a, b})            -- clipboard snapshot (full fidelity, offsets kept)
tw.paste({ position = {x=0, y=0.3, z=0} })  -- fresh copies at position + each offset; handles
```

`clone` / `reload` / `copy` / `paste` are full clones: kind, contents, transform, physics, the visual skin and the script with its data. Copying a welded group pastes a re-welded group.

## Physics casts

```lua
tw.cast({ origin = {x=0, y=1, z=0}, direction = {x=0, y=-1, z=0} })          -- ray
tw.cast({ shape = "sphere", origin = p, size = 0.1 })                        -- radius
tw.cast({ shape = "box", origin = p, size = {x=0.4, y=0.2, z=0.4} })         -- full size
```

Returns a list of hits `{ object, point, distance }`, sorted nearest first (empty if nothing). The ray reports every object it crosses within `maxDistance` (default 100 m). Sphere and box are real collider-vs-shape overlap tests; for them `point` is the object's centre.

```lua
-- what is directly below me?
local p = self:getPosition()
for _, hit in ipairs(tw.cast({ origin = {x=p.x, y=p.y + 1, z=p.z}, direction = {x=0, y=-1, z=0} })) do
  print("hit:", hit.object:kind(), hit.distance)
end
```

## Persistent storage

Table-wide state, stored in the table's database — survives restarts, saves and rollbacks:

```lua
tw.store("round", 3)              -- values round-trip through JSON, tables included
tw.get("round")                   -- 3 (nil if absent)
tw.del("round")

tw.setTable("scores", { alice = 10, bob = 7 })   -- whole-table shared state
tw.getTable("scores")             -- an independent copy; setTable again to write back
tw.setTable("scores", nil)        -- clear
```

Per-object equivalents (`obj:setVar`, `obj:setTable`) live on the [object handle](/docs/scripting-objects#shared-data-and-cross-script-calls).

## Zones

A **scripting zone** is an invisible box the engine tracks every frame. Enter/leave events fire on the script that created it. Zones are ephemeral — recreate them in `onLoad`.

```lua
function onLoad()
  discard = tw.createZone({ position = {x=0, y=0.1, z=0}, scale = {x=0.4, y=0.4, z=0.4} })
end
function onObjectEnterZone(zone, obj)
  print(obj:kind(), "entered;", #zone:getObjects(), "inside")
end
function onObjectLeaveZone(zone, obj) print(obj:kind(), "left") end
```

`tw.createZone` options:

| Key | Meaning |
| --- | --- |
| `position`, `scale` | box centre and **full** size (metres) |
| `tags` | list of tag names — only matching objects are tracked |
| `layout` | a table of layout options — makes it a layout zone (below) |
| `owner`, `hidden` | hidden zone: contents visible only to `owner`; `hidden = true` hides them from others entirely, `"masked"` shows grey silhouettes |

Zone handle methods:

```lua
zone:id()
zone:getPosition()  / zone:setPosition(v)
zone:getScale()     / zone:setScale(v)
zone:getRotation()  / zone:setRotation(45)     -- yaw degrees; containment follows
zone:getObjects()                              -- handles inside right now
zone:setHidden(ownerId, "masked")              -- re-aim the hidden mode later
zone:getOptions()   / zone:setOptions(tbl)     -- read/patch layout options
zone:layout()                                  -- force an immediate re-arrange
zone:destroy()
```

### Layout zones

With a `layout` table the zone keeps its contained (un-held) objects arranged automatically — drop cards in and they tidy into a row.

```lua
tw.createZone({ position = {x=0, y=0.1, z=-0.5}, scale = {x=1, y=0.3, z=0.3},
                tags = {"card"},
                layout = { spacing = 0.09, perRow = 0, alternate = false } })
```

Layout options (any subset; setting one implies `layout = true`):

| Key | Meaning |
| --- | --- |
| `spacing` | gap between cells, metres |
| `perRow` | columns along X; `0` = a single row |
| `direction` | `"right"`, `"left"`, `"forward"`, `"back"` — growth direction |
| `alternate` | snake rows (boolean) |
| `facing` | `"keep"`, `"up"`, `"down"` — force orientation on entry |
| `hspread`, `vspread` | per-axis spacing overrides, metres |
| `combine` | meld loose cards into decks |
| `maxPerGroup` | split melded decks into piles of at most N |
| `sort` | `"none"`, `"name"`, `"value"`, `"custom"` — custom calls your `groupSort(zone, objects)` which returns the reordered list |

## Snap points

Table-global snap points: absolute positions a *dropped* object is pulled to. Applied by the server on release — within `range` (XZ metres) the object jumps to the point and adopts its yaw. Ephemeral like zones — declare in `onLoad`.

```lua
tw.setSnapPoints({
  { position = {x = 0.25, y = 0.02, z = 0.15} },
  { position = {x = -0.25, y = 0.02, z = 0.15},
    rotation = 90, range = 0.08, tags = {"meeple"} },
})
tw.getSnapPoints()    / tw.clearSnapPoints()
```

Per-object (object-relative) snap points exist too — `obj:setSnapPoints` in the [objects reference](/docs/scripting-objects#per-object-snap-points).

## Effects and sound

```lua
local id = tw.effect({ preset = "confetti", position = {x=0, y=0.2, z=0} })
tw.stopEffect(id)

tw.playSound({ asset = "assets/bell.ogg", position = {x=1, y=0, z=-2},
               volume = 0.6, rate = 1.2 })       -- one-shot, spatialised
tw.playSound({ asset = "assets/ding.ogg" })      -- non-spatial
```

`obj:playSound` and `obj:createEffect` attach to an object instead — see the [objects reference](/docs/scripting-objects#particle-effects-and-sound). Sound assets live in the object library; paths are relative (`assets/clack.ogg`).

### Music — shared and synchronised

One authoritative track for everyone; the server derives playback position from its clock, so late joiners start at the right spot.

```lua
Music.play({ asset = "assets/ambient.ogg", volume = 0.4, loop = true })
Music.pause()   / Music.resume()   / Music.stop()
Music.seek(30000)          -- milliseconds
Music.setVolume(0.25)      / Music.setRate(1.0)

-- Ambience layers over the main track:
Music.layer({ asset = "assets/rain.ogg", loop = true, gain = 0.3 })
Music.setLayer({ id = 1, gain = 0.5, playing = true })
Music.stopLayer(1)         / Music.stopLayers()
Music.setDuck(0.5)         -- lower layers while the main track plays
```

`Music.pause("all")` / `Music.resume("layers")` scope the action to the track, the layers or both.

## Grid, hands and lighting

```lua
Grid.set({ type = "hex", size = 0.05, color = "#334455", visible = true, snap = true })
Grid.setType("rect")  / Grid.setSnapping(true) / Grid.setVisible(false)
Grid.get()

Hands.setEnabled(true)          / Hands.isEnabled()
Hands.setHiding("reverse")      -- "default" | "reverse" | "disable"
Hands.getHiding()

Lighting.set({ env = "night", exposure = 0.9,
               sun = { intensity = 2, azimuth = 45, elevation = 30 } })
Lighting.setEnv("studio")       -- "studio" | "soft" | "night"
Lighting.setExposure(1.1)       / Lighting.setSun({ color = "#ffe0c0" })
Lighting.get()
```

All three patch only the keys you pass, are server-authoritative and persist with the table.

## Console output

```lua
print("value", 42)          -- browser console, prefixed with the script's object id
tw.log("same thing")        / tw.logError("bad state")
tw.broadcastToAll("Go!")    -- a line to every player (alias tw.printToAll)
tw.printToColor("psst", 2)  -- a line to player 2 (alias tw.broadcastToColor)
```

Note the argument order of `printToColor`: message first, player second.

```lua
tw.stringColorToRGB("red")  -- seat-palette name -> {r=, g=, b=} in 0..1
```

## Wait — timers

```lua
Wait.time(fn, seconds)             -- run fn once after a delay
Wait.time(fn, seconds, reps)       -- repeat reps times (reps < 0 = forever)
Wait.frames(fn, n)                 -- run fn after n server frames
Wait.condition(fn, cond)           -- run fn when cond() first returns true
Wait.condition(fn, cond, timeout, onTimeout)
local id = Wait.time(fn, 5)
Wait.stop(id)                      / Wait.stopAll()   -- scoped to the calling script
```

Timers are dropped when their script reloads — re-arm them in `onLoad`.

## Vector

`Vector(x, y, z)` or `Vector({x=, y=, z=})`; every vector the API returns already has these methods. Operators `+`, `-`, `==` and `*` (scalar or componentwise) work.

```lua
local a = Vector(1, 0, 0) + Vector(0, 0, 1)
a:magnitude()     / a:sqrMagnitude()
a:normalized()    -- a copy; a:normalize() mutates in place
a:dot(b)          / a:cross(b)
a:distance(b)     / a:lerp(b, t)
a:scale(2)        / a:copy()
local x, y, z = a:get()
Vector.distance(a, b)   / Vector.between(a, b)
```

## Color

```lua
local c = Color(1, 0, 0)          -- r, g, b, a (a defaults to 1)
Color.Red  Color.Green  Color.Blue  Color.White  Color.Black  Color.Yellow
c:lerp(other, t)   / c:toHex()    -- "RRGGBBAA"
c:copy()
```

## JSON

```lua
local s = JSON.encode({ name = "deck", cards = {1, 2, 3} })
local t = JSON.decode(s)          -- t.name, t.cards[1]; nil on parse error
```

---

# Players and turns

Event handlers receive a numeric `player` id; `tw.getPlayer(id)` turns it into a **player handle**. `tw.getPlayers()` lists the connected ids.

```lua
function onClick(player)
  local p = tw.getPlayer(player)
  if not p then return end
  if not p:can("edit") then
    p:print("You can't edit right now.")
    return
  end
  print(p:getName(), p:getColor(), p:getSeat())
end
```

## Identity and capabilities

```lua
p:id()           -- numeric player id
p:getName()      -- display name
p:getColor()     -- seat color name ("black" = GM)
p:getSeat()      -- seat index
p:isHost()       -- true for a table manager (Owner/Admin)
p:getCaps()      -- list of capability names
p:can("play")    -- capability check: "play" | "edit" | "admin" | "owner"
                 -- (implications applied: an admin can everything a player can)
p:changeColor("blue")   -- move the player to another seat color; false if taken
```

Capabilities are the rule-enforcement primitive: combine `p:can(...)` with [veto hooks](/docs/scripting-events#veto-hooks) to decide who may do what.

## Presence: pointer, selection, hands

```lua
p:getPointerPosition()   -- cursor position on the table (Vector), nil if off-table
p:getPointerRotation()   -- the seat's facing yaw, degrees
p:getHoverObject()       -- the object under their cursor, or nil
p:getSelectedObjects()   -- handles they have selected
p:getHoldingObjects()    -- handles they are currently dragging
p:getHandCount()         -- cards in this player's hand
p:getHandObjects()       -- handles of the cards in their hand
p:getHandPosition()      -- their seat's dealing anchor (Vector)
p:getHandTransform()     -- { position, rotation, forward, right, up }
```

## Messages and dialogs

```lua
p:print("Only you see this.")            -- console line to this player (alias p:broadcast)
p:showInfoDialog("The round is over.")
p:showConfirmDialog("End your turn?", function(ok)
  if ok then Turns.endTurn() end
end)
p:showInputDialog("Bet how much?", "10", function(ok, value)
  if ok then self:setVar("bet", tonumber(value) or 0) end
end)
p:showMemoDialog("Session notes", "", function(ok, text) ... end)   -- multi-line
p:showOptionsDialog("Pick a class", { "Warrior", "Mage", "Rogue" }, 1,
  function(ok, choice) ... end)                                     -- choice = the string
p:showColorDialog(function(ok, colorName) ... end)                  -- seat palette
p:chooseInHand(function(card) ... end)   -- private pick from their own hand (nil = cancel)
```

Dialog callbacks are **one-shot**: they fire once, and are dropped if the owning script reloads before the player answers.

## Camera and pings

```lua
p:lookAt({ position = Vector(0, 0, 0), distance = 1.2, pitch = 55, yaw = 90 })
p:setCameraMode("top")        -- "top" | "front" | "side" | "free" | "default"
p:getCameraMode()
p:attachCameraToObject({ object = hero, distance = 0.8, pitch = 45 })
p:detachCameraFromObject()
p:ping(0.1, 0, 0.2)           -- a brief ping ring on the table (also accepts a Vector)
```

## Tools

Drive the player's client-side tooling — a script toolbar can switch the native tool for them:

```lua
p:setTool("draw", { mode = "pencil", color = "#3366ff", width = 4 })
p:setTool("")                       -- back to the pointer
p:setToolOptions({ color = "#ff0000" })   -- tweak without switching
```

Tool ids: `draw`, `zone`, `snap`, `measure`, `marker`, `gizmoMove`, `gizmoRotate`, `gizmoScale`, or `""`/nil for the pointer. Options: draw `mode`/`color`/`width`, gizmo `snap`/`space`, measure `unit` — only the keys you pass change.

## Moderation

Moderation is trusted: scripts are authored by whoever controls the table, so these take effect from any script.

```lua
p:mute()             / p:mute(false)      -- freeze / unfreeze their game actions
p:getMuted()
p:setBlindfolded(true)                    -- black out their view
p:getBlindfolded()
p:promote()          / p:demote()         -- raise to / drop from table manager
p:kick()                                  -- disconnect from the table
```

`mute` drops the player's capability-gated actions server-side without changing their configured caps. `promote`/`demote` adjust the capability set and always preserve the "a manager exists" invariant.

## Turns

`Turns` is the built-in turn-order system. State is server-side; everyone sees the turn indicator. `onPlayerTurn(current, previous)` fires on the Global script when the turn moves.

```lua
Turns.enable(true)          -- start turns (default order: seated players by seat)
Turns.isEnabled()
Turns.current()             -- whose turn it is (player id; 0 = none)
Turns.order()               / Turns.setOrder({3, 1, 2})
Turns.endTurn()             -- advance (alias Turns.next())
Turns.previous()            -- step back
Turns.getNext()             / Turns.getPrevious()   -- peek without moving
Turns.pass()                -- advance, but only while passing is enabled
```

Behaviour flags (get/set pairs):

```lua
Turns.setReverse(true)              / Turns.getReverse()
Turns.setSkipEmpty(true)            / Turns.getSkipEmpty()            -- skip empty seats
Turns.setPassEnabled(true)          / Turns.getPassEnabled()
Turns.setDisableInteractions(true)  / Turns.getDisableInteractions()  -- only the current
                                    -- player may act while enabled
```

Enforce turns yourself with the global `onPlayerAction` veto when you need finer rules:

```lua
function onPlayerTurn(cur, prev)
  tw.broadcastToAll(tw.getPlayer(cur):getName() .. "'s turn")
end
function onPlayerAction(player, action, target)
  if Turns.isEnabled() and player ~= Turns.current() then return false end
end
```

---

# Custom UI

Scripts put their own controls into the app: ribbon tabs and items, a corner panel, free-floating panels, context-menu entries and hotkeys. The base UI itself is built on this same API, so script controls look and behave exactly like the native ones.

Callbacks fire on the script that declared them, with `(player)` for a button and `(player, value)` for anything that carries a value. **Contributions are cleared on script reload — declare them in `onLoad`.**

## The control vocabulary

One shared control table format is used by ribbon items, the corner panel and floating panels. The kind key's value is the label:

| Kind | Extra keys | Callback |
| --- | --- | --- |
| `button = "Roll"` | — | `onClick(player)` |
| `toggle = "Auto"` | `on` (initial, default false) | `onChange(player, on)` |
| `slider = "Speed"` | `min` 0, `max` 100, `step` 1, `value` 0 | `onChange(player, n)` |
| `select = "Mode"` | `options = {"a","b"}`, `value` | `onChange(player, choice)` |
| `color = "Tint"` | `value` ("#ffffff") | `onChange(player, hex)` |
| `input = "Name"` | `value`, `placeholder` | `onChange(player, text)` on commit |
| `image = "<src>"` | `width`, `height` (px) | `onClick(player)` |
| `progress = "Load"` | `value` 0..1, read-only | drive via `setValue` by id |
| `text = "Score: 0"` | panel/corner-panel only | — |
| `{ separator = true }` | floating panels only | — |

Option keys valid on any control:

- `id` — needed to update the control later (`tw.ribbon.setValue`, `panel:setValue`, `tw.setUIValue`).
- `icon` — a context-menu icon name (`shuffle`, `rotl`, `layers`, `dice`, `lock`, `play`, ...); `iconColor` tints it.
- `disabled` — render greyed out.
- `column` — ribbon only: controls sharing a column index stack vertically.
- `locKey` — an i18n key resolved on the client instead of the literal label.
- `client` — instead of a callback, a client-local action with no server round-trip (below).
- `command` — instead of a callback, a native server command (`flip`, `shuffle`, `lock`, ...).

Recognised `client` actions: `panel:<id>` (toggle library/players/timemachine/settings), `spawn:<kind>` (spawn at the cursor), `tool:<id>` (switch tool; `""` = pointer), `editor:global` (open the table-script editor), `cmd:savetable`, `cmd:export`.

## Ribbon

```lua
function onLoad()
  tw.ribbon.addTab("My Game", {
    { group = "Turn", items = {
      { button = "Roll all", icon = "dice", onClick = "rollAll" },
      { toggle = "Auto draw", id = "auto", on = false, onChange = "setAuto" },
    }},
    { group = "Pace", items = {
      { slider = "Speed", id = "spd", min = 1, max = 10, value = 5, onChange = "onSpeed" },
      { progress = "Round", id = "prog", value = 0 },
    }},
  })
  -- or add to an existing tab/group:
  tw.ribbon.addItem("table", "Tools", { button = "Reset", icon = "rotl", onClick = "reset" })
end

function rollAll(player) ... end
function onSpeed(player, n)
  tw.ribbon.setValue("prog", n / 10)   -- live-update any control by id
end

tw.ribbon.clear()   -- drop this script's ribbon contributions
```

## Corner panel

A flat list of elements in a fixed corner — the quickest scoreboard:

```lua
local score = 0
function onLoad()
  tw.setUI({
    { text = "Score: 0", id = "score" },
    { button = "Add point", onClick = "add" },
    { toggle = "Hard mode", id = "hard", on = false, onChange = "setHard" },
  })
end
function add(player)
  score = score + 1
  tw.setUIValue("score", "Score: " .. score)
end

tw.clearUI()
```

## Floating panels

Positioned, laid-out toolbars — the built-in drawing toolbar is one of these. Same controls as the ribbon plus `text` and `{ separator = true }`; several can run at once.

```lua
function onLoad()
  panel = tw.createPanel({
    id = "brush", anchor = "top", layout = "row", title = "Brush",
    elements = {
      { button = "Clear", icon = "rotl", onClick = "onClear" },
      { separator = true },
      { toggle = "Snap", id = "snap", on = false, onChange = "onSnap" },
      { slider = "Size", id = "size", min = 1, max = 20, value = 4, onChange = "onSize" },
      { select = "Shape", id = "shape", options = { "line", "rect", "ellipse" }, onChange = "onShape" },
      { color = "Colour", id = "col", value = "#3366ff", onChange = "onColour" },
    },
  })
end
function onSize(player, n) panel:setValue("size", n) end
```

`anchor` is `top` / `bottom` / `left` / `right` / `center`, or `free` with `x` / `y` screen pixels. The handle has `:setValue(id, v)`, `:setTitle(t)`, `:close()`; the non-handle forms are `tw.setPanelValue(panelId, id, v)` and `tw.closePanel(panelId)`.

## Context menus

Right-click entries — on an object (`obj:addContextMenuItem`) or on the empty table (`tw.addContextMenuItem`). Either `(label, fnName)` or a table:

```lua
tw.addContextMenuItem({ label = "Show grid", icon = "layers", category = "view",
                        toggle = true, checked = true, onClick = "onGrid", keepOpen = true })
function onGrid(player, on) ... end

self:addContextMenuItem({ label = "Flip", command = "flip", icon = "flip" })   -- native command
self:addContextMenuItem({ label = "Deal", onClick = "deal", slider = { min = 1, max = 10 } })
self:addContextMenuItem({ label = "Spread", onClick = "spread", options = { "faceup", "facedown" } })
```

Fields: `label`, `onClick` (or `fn`) **or** `command`, `icon`, `category` (groups entries with dividers; `"danger"` renders red), `toggle` + `checked`, `keepOpen` (keep the menu up for repeated clicks), `slider = {min, max}` (numeric pick passed to the callback), `options = {...}` (a submenu of choices). Remove with `tw.clearContextMenu()` / `obj:clearContextMenu()`.

## Hotkeys

```lua
tw.addHotkey("r", "rollAll", "Roll all")    -- global: fires when not typing in a field
self:addHotkey("f", "onF", "Flip me")       -- object-scoped: only while THIS object
                                            -- is hovered or focused
tw.clearHotkeys()   / self:clearHotkeys()
```

Callbacks get `(player)`.

## Hiding built-in UI

The base UI is itself declared through this API, so a script can suppress any of it by id — build a focused play mode, hide tools from players, or replace a whole toolbar with your own:

```lua
tw.hideUI("group:draw")     -- the whole drawing toolbar
tw.hideUI("spawn:die")      -- the "spawn die" ribbon button
tw.hideUI("panel:players")  -- the Players button
tw.hideUI("toss")           -- the "toss" object-menu action
tw.showUI("toss")           -- bring it back
```

Ids use the same vocabulary as `client` actions and menu commands: `tool:<id>`, `panel:<id>`, `spawn:<kind>`, a control's `id`, a menu action name, or `group:<name>` for a bespoke ribbon group (grid/gizmo/camera/draw/measure/snap/zones/policy). Hides clear when the script reloads.

## Tablets

For in-world UI — a screen object players gather around rather than overlay chrome — spawn a tablet with a document and drive its elements from any script: see [tablet elements](/docs/scripting-objects#tablet-elements) and `tw.spawnTablet`.

---

# Scripting recipes

Copy-paste starting points. Each recipe is a complete script for the object named in its title (or the Global script where said).

## A deal button

Attach to any object sitting next to a deck; clicking it deals one card to the clicking player.

```lua
function onClick(player)
  local deck = self:objectBelow()
  if deck and deck:kind() == "deck" then
    deck:dealToHand(player, 1)
  end
end
```

## Roll everything, announce the total

Global script: a ribbon button rolls every die, then a condition-timer waits for them to settle and announces the sum.

```lua
function onLoad()
  tw.ribbon.addItem("table", "Dice", { button = "Roll all", icon = "dice", onClick = "rollAll" })
end

function rollAll(player)
  local dice = {}
  for _, o in ipairs(tw.getObjects()) do
    if o:kind() == "die" then o:roll(); dice[#dice + 1] = o end
  end
  if #dice == 0 then return end

  Wait.condition(function()
    local sum = 0
    for _, d in ipairs(dice) do sum = sum + (d:getValue() or 0) end
    tw.broadcastToAll("Rolled " .. sum)
  end, function()
    for _, d in ipairs(dice) do
      if d:getValue() == nil then return false end   -- still tumbling
    end
    return true
  end, 10)   -- give up after 10 s
end
```

## A scoring zone

Global script: anything tagged `vp` dropped into the zone updates a counter named `score`.

```lua
function onLoad()
  tw.createZone({ position = {x = 0.6, y = 0.1, z = 0.4},
                  scale = {x = 0.3, y = 0.3, z = 0.3}, tags = {"vp"} })
end

local function refresh(zone)
  local counter = tw.getObjectFromGUID("score")
  if counter then counter:setValue(#zone:getObjects()) end
end

function onObjectEnterZone(zone, obj) refresh(zone) end
function onObjectLeaveZone(zone, obj) refresh(zone) end
```

## A custom d3 from any object

Rotation values turn any prop into a die; `randomize` (the R key) tosses it.

```lua
function onLoad()
  self:setRotationValues({
    { value = 1, rotation = {0, 0, 0} },
    { value = 2, rotation = {90, 0, 0} },
    { value = 3, rotation = {0, 0, 90} },
  })
end

function onRandomize(player)
  Wait.condition(function()
    tw.broadcastToAll("It landed on " .. tostring(self:getRotationValue()))
  end, function() return self:getValue() ~= nil end, 8)
end
```

## House rules: turn order enforced

Global script: only the current player may act, except managers; flipping the score counter is always forbidden.

```lua
function onLoad()
  Turns.enable(true)
end

function onPlayerTurn(cur, prev)
  local p = tw.getPlayer(cur)
  if p then tw.broadcastToAll(p:getName() .. "'s turn") end
end

function onPlayerAction(player, action, target)
  local p = tw.getPlayer(player)
  if p and p:isHost() then return end            -- managers bypass the rules
  if target and target:guid() == "score" then return false end
  if Turns.isEnabled() and player ~= Turns.current() then return false end
end
```

## Owner-only pieces

Object script: only the player recorded in the `owner` var may pick it up or flip it.

```lua
local function isOwner(player)
  return self:getVar("owner") == nil or self:getVar("owner") == player
end

function tryGrab(player) return isOwner(player) end
function tryFlip(player) return isOwner(player) end

function onDrop(player)
  if self:getVar("owner") == nil then
    self:setVar("owner", player)     -- first to place it claims it
  end
end
```

## Templated spawning

Serialize a fully dressed object once, stamp copies later — the snapshot keeps the skin, script and contents.

```lua
-- On the template object, e.g. from its context menu:
function onLoad()
  self:addContextMenuItem("Save as template", "save")
  tw.addHotkey("g", "spawnOne", "Spawn goblin")
end

function save(player)
  tw.store("goblin", self:getJSON())
  tw.getPlayer(player):print("Template saved.")
end

function spawnOne(player)
  local json = tw.get("goblin")
  if json then
    local p = tw.getPlayer(player):getPointerPosition() or Vector(0, 0.3, 0)
    tw.spawnObjectJSON({ json = json, position = {x = p.x, y = 0.3, z = p.z} })
  end
end
```

## Hidden information

A GM screen: objects the GM drops into the hidden zone are visible only to them; everyone else sees silhouettes.

```lua
-- Global script. Player 1 is the GM here; use p:can("admin") to find one dynamically.
function onLoad()
  tw.createZone({ position = {x = -0.7, y = 0.1, z = 0}, scale = {x = 0.4, y = 0.3, z = 0.4},
                  owner = 1, hidden = "masked" })
end
```

Per-object visibility works without zones too:

```lua
card:setInvisibleTo({2, 3})   -- players 2 and 3 don't see it at all
card:setHiddenFrom({2})       -- player 2 sees an anonymous grey shape
```

## A tidy discard pile

A layout zone that melds dropped cards into one face-up deck.

```lua
function onLoad()
  tw.createZone({ position = {x = 0.45, y = 0.1, z = -0.2},
                  scale = {x = 0.25, y = 0.3, z = 0.35}, tags = {"card"},
                  layout = { combine = true, facing = "up" } })
end
```
