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 and the UI builders.
Anywhere a function takes an object you may pass a handle, a numeric id or a guid string.
Finding objects
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
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):
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:
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
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
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.
-- 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:
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.
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.
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:
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.
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.
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.
Effects and sound
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. 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.
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
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
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.
tw.stringColorToRGB("red") -- seat-palette name -> {r=, g=, b=} in 0..1
Wait — timers
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.
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
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
local s = JSON.encode({ name = "deck", cards = {1, 2, 3} })
local t = JSON.decode(s) -- t.name, t.cards[1]; nil on parse error