ObjectHandle
An ObjectHandle is one entity on the table, as a table script sees it. Every handle carries the ten read-only
fields of ObjectData, fourteen methods, and eight event delegates that fire only for that entity.
A handle whose entity is a card, a deck, a bag or a card holder is also one of the narrower types on
Object Types, which add the members only that kind has.
Handles come from four places: world.getObjectById,
world.getAllObjects,
world.spawnObject, and the payload of a globalEvents delegate.
In an object script, refObject is the handle for the entity the script is attached to.
A handle is a cache, not a live view. Its ObjectData fields hold the state the host last reported, and they
update when the handle is delivered by a query, delivered by an event, or refreshed with
refresh(). The twelve mutators all return void immediately: they post an intent and do
not wait, so the handle you called one on still reports the old value on the next line.
The sandbox keeps one handle object per id. Two lookups of the same entity give you the same JavaScript object, so holding a handle in a variable and refreshing it is equivalent to looking it up again.
Nine of the fourteen methods are object actions#
Members below is the generated signature table for all fourteen, followed by a full entry for each. It is derived from the declarations, so it cannot drift from them.
flip, rotate, lock, unlock, roll, shuffle, draw, deal and destroy each post an object-action
intent. destroy() is the one whose method name differs from its action name — it sends delete.
Four action names in the table-script vocabulary have no method here at all: tap, untap, split and
combine. They are in the ObjectAction type union and the script host's allowlist accepts them, so nothing
below the typed API is missing — only the calling surface is. Do not write code that assumes a method for them.
Action vocabularies has the full three-way comparison and classifies each
gap; Known limitations lists them
alongside every other documented gap.
Events#
This page also carries ObjectHandle's eight object-scoped delegates — onCreated, onDestroyed, onPickedUp,
onDropped, onAction, onRolled, onCardDrawn and onShuffled. Each is the entity-scoped twin of a
globalEvents delegate and fires only when the event's entity is this one — with one deliberate exception:
onCardDrawn is routed to the container the card came from, not to the card, because deck.onCardDrawn is
what an author means. See
Events for how delegates work, which peer fires them, and how many times.
What a handle can see#
ObjectData is the read-only state every handle carries, and it is also what refresh() resolves
with — as a plain copy with no methods and no delegates. Its ten fields, with an entry for each, are in the
ObjectData section below.
Three things a table script cannot see about an entity: its displayName, its parentId, and its engine
components. All three exist on the replicated entity state and none is published here. Mod scripting is not
affected — a mod reads all three off the state its peer received, subject to hidden-information redaction
(which deletes displayName from a card that peer may not identify). See
Parenting is invisible to table scripts
and A table script cannot read displayName.
See also#
World— where handles come from.- Events — the delegates that hand you a handle, and the eight on this page.
- Types —
Vec3,ObjectKind,ObjectAction. - Action vocabularies — 19 engine actions, 13 a script may request, 9 with a method.
- Object kinds — what each kind is and how it behaves.
- Object state — the full replicated state, including the fields
ObjectDataomits. - Host authority — why a mutator returns before anything has changed.
- Known limitations — every documented gap, in one list.
ObjectHandle#
Surface A — table script · interface · 23 members
An ObjectHandle is the sandbox's cached record of one entity, wrapped in the methods and delegates that act on
it. The ten ObjectData fields on it are getter-only accessors over a record the sandbox
owns and rewrites: there is no setter behind any of them, so handle.position = [0, 2, 0] moves nothing and
never reaches the host. setPosition is the only way to move an entity, and it moves the entity rather than the
handle.
The sandbox keys handles by id in a map it never prunes, which is what makes "one handle object per id" true for
the whole session — the handle an event gives you in minute one is identity-equal to the one a query gives you in
minute forty, and refreshing either updates both. It also means a handle outlives the entity: after destroy()
the object is still in the map, still answers its ten fields with the last state anyone saw, and its
refresh() resolves null.
How, why and when to use it#
You are writing the rule that fires when a card lands in a zone, and you need to know what that card is before you
decide what to do. The delegate hands you a handle rather than an id precisely so you do not have to go back to
world.getObjectById for the state you already have — the sandbox populated the handle from the same event
payload. Reach for world.getObjectById only when all you were given is an id (onObjectDestroyed hands you a
bare string), and reach for refresh() when time has passed inside your handler and you need the host's
current answer rather than the one that arrived with the event.
Gotchas#
A handle reaches the table through only two intent types. setPosition and setRotation post a transform;
flip, rotate, lock, unlock, roll, shuffle, draw, deal and destroy post an object-action.
Nothing else on a handle writes to the table, which is why an entity's tags, metadata and label are fixed at
spawn.
Do not hold a handle as a "before" value. A refresh(), a query or an event anywhere in your script rewrites
the same record you are reading, so comparing a stored handle against the live table compares an object with
itself. Copy the fields you want to compare into your own variables.
An entity's displayName, parentId and engine components are not on it. All three exist on the replicated
entity state and none is published to a table script. See
A table script cannot read displayName
and Parenting is invisible to table scripts.
See also#
ObjectData— the ten fields, and what each one is populated from.world.getObjectById— resolving a handle from an id.refObject— the handle an object script starts with.ObjectHandle.refresh— re-reading an entity from the host.- Async and snapshots — what an
awaitinvalidates.
Members#
| Signature | Description | Returns |
|---|---|---|
onCreated |
ScriptDelegate<[ObjectHandle, EventContext]> |
|
onDestroyed |
Fires when this entity leaves the table. context.reason says why, and for "absorbed" context.containerId names the stack that took it. |
ScriptDelegate<[EventContext]> |
onPickedUp |
ScriptDelegate<[ObjectHandle, EventContext]> |
|
onDropped |
ScriptDelegate<[ObjectHandle, EventContext]> |
|
onAction |
ScriptDelegate<[ObjectHandle, ObservedObjectAction, EventContext]> |
|
onRolled |
Fires when this die settles after a roll. value is the face value when known. |
ScriptDelegate<[ObjectHandle, number | null, EventContext]> |
onCardDrawn |
Fires on a CONTAINER when a card is drawn from it: the handle argument is the newly created card, and context.containerId is this container's id. |
ScriptDelegate<[ObjectHandle, EventContext]> |
onShuffled |
ScriptDelegate<[ObjectHandle, EventContext]> |
|
onMenuItem |
A player clicked one of this script's menu entries ON THIS ENTITY. The string is your own item id. The item still has to match this entity to be offered at all. |
ScriptDelegate<[ObjectHandle, string, EventContext]> |
refresh() |
Refresh and return this object's latest state (null if it no longer exists). | Promise<ObjectData | null> |
setPosition(position: Vec3) |
void |
|
setRotation(rotation: Vec3) |
void |
|
flip() |
void |
|
rotate() |
void |
|
lock() |
void |
|
unlock() |
void |
|
roll() |
Roll this object (dice). | void |
shuffle(options?: ObjectActionOptions) |
Shuffle this container/deck. Pass { silent: true } to reorder without the spin or the riffle — for setup a player is not meant to be watching. | void |
draw() |
Draw the top item from this deck/bag. | void |
deal() |
Deal cards from this deck to players. | void |
destroy() |
Remove this object from the table. | void |
getSavedData(key?: string) |
Promise<string | null> |
|
setSavedData(value: string, key?: string) |
Promise<void> |
objecthandle.onCreated#
readonly onCreated: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when the entity this handle addresses comes into existence — a spawn intent, or one of the gameplay
paths that creates an entity. It is the entity-scoped twin of
globalEvents.onObjectCreated, and it fires
immediately after it, for the same event.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The same handle you subscribed on. The sandbox keeps one handle per id, so this is the identical object, refilled from the host's state. |
| 2 | EventContext |
Who spawned it — "Script" for a world.spawnObject call, "You" for the local Add menu, or a peer id. |
Applies to: every object kind.
How, why and when to use it
This delegate has exactly one moment where it can fire, and it is a genuinely useful one: the handle
world.spawnObject hands back resolves before the host has
validated anything, so a non-null handle only means the request was well-formed. Subscribing onCreated on
that handle turns the optimistic result into a confirmation — when it fires, the entity really is on the table.
The alternative is await handle.refresh() and a null check, which is what to reach for when you want a single
answer now; use onCreated when you want to carry on and be told later, without holding an await open.
Example
// content/scripting-api/examples/objecthandle.onCreated.ts
// Scene script: confirm a spawn landed. spawnObject resolves optimistically,
// so the handle exists before the host has applied anything; onCreated on that
// same handle is the host confirming the entity is really on the table.
async function spawnConfirmedDie(): Promise<void> {
const die = await world.spawnObject({
kind: "die",
name: "confirmed-die",
presetId: "die-d6",
position: [0, 1, 0]
});
if (!die) {
world.log("spawnObject refused the request: kind must be a non-empty string.");
return;
}
world.log(`Requested ${die.id}; waiting for the host to confirm it.`);
die.onCreated.add((entity, context) => {
world.log(`Confirmed ${entity.id} (${entity.kind}) created by ${context.actor}.`);
entity.roll();
});
// If the host rejected the definition, no event ever arrives - check.
await world.wait(3);
const applied = await die.refresh();
if (applied === null) {
world.log(`${die.id} was never created - read the script console for the diagnostic.`);
}
}
void spawnConfirmedDie();
The script console prints Requested script-1f2e3d4c5b6a7089; waiting for the host to confirm it. and then
Confirmed script-1f2e3d4c5b6a7089 (die) created by Script.
Gotchas
An object script attached at start never sees its own refObject.onCreated. That entity was created before
the script host booted, so there is nothing to fire for, and the top of the script body is the "this entity is
ready" moment. The exception is an entity created while scripts are running: its object script is attached
before the event is dispatched, so refObject.onCreated does fire — see
Execution order.
You have to hold the handle before the entity exists. Otherwise the only way to subscribe in time is to have
the handle already, which in practice means the one world.spawnObject returned. A handle you fetch afterwards
with world.getObjectById is too late.
No event arrives when the host rejects the spawn. A definition that fails schema validation — an over-long
tag is the usual cause — produces a diagnostic and nothing else. Pair the subscription with a timeout and a
refresh(), as the example does. See
Tag validation is asymmetric.
The table-wide delegate fires first. globalEvents.onObjectCreated fans out before this one, for the same
event.
See also
globalEvents.onObjectCreated— the table-wide twin, and the two paths that raise it.world.spawnObject— where the handle comes from.ObjectHandle.refresh— the one-shot alternative to subscribing.- Async and snapshots — what "optimistic" means in full.
- Execution order — why an object script's own entity is already there.
objecthandle.onDestroyed#
readonly onDestroyed: ScriptDelegate<[EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when this entity leaves the table. context.reason says why, and for
"absorbed" context.containerId names the stack that took it.
Fires when the entity this handle addresses leaves the table. It is the one delegate whose signature differs from its table-wide twin: it receives only the context, because the handle is already the subject and there is nothing left to hand you. Immediately after the fan-out the sandbox discards the handle from its cache, so this is the last moment the entity exists as far as your script is concerned.
Parameters
The handler receives one argument, transcribed from ScriptDelegate<[EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | EventContext |
Who removed it, and why. actor is "Script" for a destroy() call, a peer id for a player, "You" on a solo table. reason is one of "deleted", "depleted", "converted" or "absorbed", and on "absorbed" containerId names the stack that took this entity. |
Applies to: every object kind. A deck also raises it when a draw empties it or reduces it to one card, and any card-like entity raises it when a combine folds it into a stack.
How, why and when to use it
An object script is holding a running total for its own piece — a token's hit points, a deck's shuffle count —
and the piece is about to disappear. This is where you flush it: write it to saved data, tell the table, hand a
replacement out. The alternative is checking for a null from refresh() on the next read, which tells you the
entity is gone but not when, not who, and not in time to say anything about it. Use onDestroyed when the
disappearance is worth reacting to; rely on a null from refresh() when you only need the current answer.
Example
// content/scripting-api/examples/objecthandle.onDestroyed.ts
// Object script: this delegate receives the context and nothing else - the
// handle is the subject, so there is nothing to hand you. Read whatever you
// need from the entity BEFORE it goes, because the sandbox drops the handle
// immediately after this fan-out.
const label = refObject.name ?? refObject.kind;
const startedAt = Date.now();
refObject.onDestroyed.add((context) => {
const lifetime = Math.round((Date.now() - startedAt) / 1000);
world.broadcast(`${label} was removed by ${context.actor} after ${lifetime}s.`);
world.log(`${label}: handle discarded; further reads would address nothing.`);
});
refObject.onPickedUp.add((entity, context) => {
// Cached values stay valid while the entity exists - the event refreshes
// the handle just before your handler runs.
world.log(`${label} lifted by ${context.actor} from [${entity.position.join(", ")}] ft.`);
});
world.log(`Watching the lifetime of ${label} (${refObject.id}).`);
Deleting the piece puts red-token was removed by You after 47s. into the table chat and prints
red-token: handle discarded; further reads would address nothing. to the script console.
Gotchas
The handle is dropped from the cache right after your handler returns. The sandbox deletes the entry once the
entity-scoped fan-out finishes (apps/web/src/scripting/sandbox/tableScriptSandbox.html,
routeLifecycleEvent), so a later world.getObjectById on that id builds a new, empty handle rather than
returning yours — and your registered handlers are not on it. Read what you need inside the handler.
No handle argument, by design of the payload. Read the entity's fields from the outer refObject, or cache
what you need at startup as the example caches label. They still hold the last state the host reported.
onAction with "delete" fires first, and so does the table-wide onObjectDestroyed. For a delete the
order is globalEvents.onObjectAction, ObjectHandle.onAction, globalEvents.onObjectDestroyed, then this.
A deck can be destroyed without anyone deleting it. Drawing the last card removes the deck
(reason: "depleted"), drawing down to one card converts the remainder to a card and removes the deck entity
(reason: "converted"), and a combine folds a card or a deck into another stack (reason: "absorbed"). Check
context.reason before you treat the disappearance as a loss —
an absorbed entity's cards are still in play inside context.containerId, and they come back out as new
entities with new ids.
See also
-
ObjectDestroyedReason— the four causes, and the runtime path behind each. -
globalEvents.onObjectDestroyed— the table-wide twin, which receives an id. -
ObjectHandle.destroy— the call that raises this from a script. -
ObjectHandle.setSavedData— where a per-entity total should be flushed to. -
EventContext— the only argument this delegate passes. -
Events and delegates — why this delegate's two scopes differ.
objecthandle.onPickedUp#
readonly onPickedUp: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when the entity this handle addresses is lifted off the table. It is the entity-scoped twin of
globalEvents.onObjectPickedUp and fires immediately
after it, with the same two arguments — a remote player's drag, a desktop grab and a VR grab all reach it.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The same handle you subscribed on, refilled from the event's state — so position is where the entity was picked up from. |
| 2 | EventContext |
A peer id for a remote player, "You" for a grab on the host machine. Never "Script". |
Applies to: every object kind.
How, why and when to use it
An object script on a piece wants to remember where that piece started, so a bad placement can be undone. On the
handle there is nothing to filter: the delegate fires only for this entity, so the handler has no if (entity.id === …) at the top. The alternative is globalEvents.onObjectPickedUp plus that comparison, which is
the right choice when one script watches many pieces or when the set of pieces is not known at author time.
Subscribe on the handle when the rule belongs to the piece; subscribe on globalEvents when the rule belongs to
the game.
Example
// content/scripting-api/examples/objecthandle.onPickedUp.ts
// Object script: remember where this piece started so a drop can put it back.
// Attaching the script to the piece means no id filtering - this delegate
// fires only for the entity the script is attached to.
let home: Vec3 | null = null;
refObject.onPickedUp.add((entity, context) => {
if (home === null) {
home = [entity.position[0], entity.position[1], entity.position[2]];
world.log(`${entity.name ?? entity.id}: home recorded at [${home.join(", ")}] ft.`);
}
world.log(`${entity.name ?? entity.id} lifted by ${context.actor}.`);
});
refObject.onDropped.add((entity, context) => {
if (home === null) {
return;
}
const drift = Math.abs(entity.position[0] - home[0]) + Math.abs(entity.position[2] - home[2]);
if (drift > 4) {
entity.setPosition(home);
world.broadcast(`${entity.name ?? entity.id} went too far and was returned.`);
} else {
world.log(`${entity.name ?? entity.id} placed by ${context.actor}; drift ${drift.toFixed(2)} ft.`);
}
});
world.log(`Home-position keeper is watching ${refObject.id}.`);
Lifting the piece prints red-token: home recorded at [1, 0.35, -2] ft. and red-token lifted by You.;
releasing it more than four feet away puts red-token went too far and was returned. into the table chat.
Gotchas
By design. A grab on a member of a parented assembly escalates to the root ancestor (
apps/web/src/playcanvas/TabletopRuntime.ts,resolveGrabTarget), so an object script attached to a child is never told its own piece was picked up — the root's delegate fires instead. Escalation stops at the first ancestor that is locked or that the actor may not drag; Alt+grab targets the child on desktop, and a VR grab escalates unconditionally, because a headset has no Alt. Mark a child that genuinely should move on its own withgrabbableWhileParentedin itsmetadata— the exemption is ignored while the assembly is welded or an ancestor is restricted. Attach a script that cares about pickups to the piece a player actually grabs.
A scripted move raises nothing. setPosition teleports without a hold, so the correction in the example
cannot re-enter its own handler.
refObject exists only in an object script. The declaration types it as always present and it is
undefined in a scene script. In a scene script, get a handle from world.getObjectById and subscribe on that.
See Known limitations.
See also
globalEvents.onObjectPickedUp— the table-wide twin, and multiplicity for a group drag.ObjectHandle.onDropped— the end of the same hold.ObjectHandle.setPosition— putting a piece back.- Grab events report the assembly root — escalation, in full.
- Events and delegates — scope, ordering and multiplicity.
objecthandle.onDropped#
readonly onDropped: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when the entity this handle addresses is released. It is the entity-scoped twin of
globalEvents.onObjectDropped and fires immediately
after it. The entity has been let go but has not settled — the position you read is the release point.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The same handle you subscribed on, refilled from the event's state. |
| 2 | EventContext |
A peer id for a remote player's release, "You" for a release on the host machine. Never "Script". |
Applies to: every object kind.
How, why and when to use it
A piece has to sit in one of four lanes, and players are not going to place it exactly. This delegate is where a
piece enforces its own placement rule: it fires only for this entity, so the handler is the rule and nothing
else. The alternative is globalEvents.onObjectDropped with an id comparison, which is what you want when the
rule is about the board rather than the piece — a zone that accepts anything, say. Put the rule on the piece when
every copy of that piece needs it; put it on globalEvents when the rule is about where things land.
Example
// content/scripting-api/examples/objecthandle.onDropped.ts
// Object script: snap this piece to the nearest lane when a player lets go.
// Nothing can refuse a drop, so the pattern is always react-and-correct: let
// it land, then push it where the rules say it belongs.
const LANES = [-3, -1, 1, 3];
function nearestLane(x: number): number {
let best = LANES[0];
for (const lane of LANES) {
if (Math.abs(lane - x) < Math.abs(best - x)) {
best = lane;
}
}
return best;
}
refObject.onDropped.add((entity, context) => {
const [x, y, z] = entity.position;
const lane = nearestLane(x);
if (Math.abs(lane - x) < 0.05) {
world.log(`${entity.name ?? entity.id} already sits in lane ${lane}.`);
return;
}
entity.setPosition([lane, y, z]);
world.log(`${context.actor} dropped ${entity.name ?? entity.id} at x=${x.toFixed(2)}; snapped to lane ${lane}.`);
});
world.log(`Lane snapper is watching ${refObject.id}.`);
Dropping the piece anywhere near a lane prints You dropped red-token at x=-2.62; snapped to lane -3.
Gotchas
The position is the release point, not the resting place. Physics continues after the event, so a piece
dropped at speed keeps travelling. Snap immediately, as the example does, or await world.wait(1) and
await entity.refresh() if the rule depends on where it actually stops.
Nothing can refuse the drop. By the time this runs the host has applied it and broadcast it. React and correct. See Nothing can cancel an action.
By design. A drop on a member of a parented assembly reports the root ancestor, so an object script on a child is never told about the drop — the root's delegate fires instead (
apps/web/src/playcanvas/TabletopRuntime.ts,resolveGrabTarget). Escalation stops at the first ancestor that is locked or that the actor may not drag, Alt+grab targets the child on desktop, and a VR grab escalates unconditionally. Attach a placement rule to the piece a player actually grabs, or mark the childgrabbableWhileParentedin itsmetadata.
A group drag raises this once per surviving member. A piece consumed by a combine or a shuffle during the
hold raises onDestroyed instead.
See also
globalEvents.onObjectDropped— the table-wide twin, and turn accounting.ObjectHandle.onPickedUp— the start of the same hold.ObjectHandle.setPosition— the correction itself.- Grab events report the assembly root — escalation, in full.
- Events and delegates — scope, ordering and multiplicity.
objecthandle.onAction#
readonly onAction: ScriptDelegate<[ObjectHandle, ObservedObjectAction, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires for every object action applied to the entity this handle addresses. It is the entity-scoped twin of
globalEvents.onObjectAction and fires immediately
after it, with the same three arguments. Like its twin, it fires in addition to the semantic delegate for that
action — one roll on this entity calls onAction and then onRolled.
Parameters
The handler receives three arguments, transcribed from
ScriptDelegate<[ObjectHandle, ObservedObjectAction, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The same handle you subscribed on, refilled from the event's state. For delete it carries the entity's last known state, because the entity is already gone. |
| 2 | ObservedObjectAction |
The action name — all 19 engine names: the 13 a script may request plus lift, flick, press and the three reveal-* actions. |
| 3 | EventContext |
A peer id, or "Script" / "Host" / "You" / "System". |
Applies to: every object kind. The event fires whether or not the runtime does anything with the action for this kind — see Action vocabularies.
How, why and when to use it
A card that should not be flipped face-up until it is played, a token that locks itself after three flips, a
piece that logs everything done to it: all of these are rules that belong to one entity, and this is the one
delegate on the handle that sees every kind of interference. The alternative is subscribing to onRolled,
onShuffled and onDestroyed individually, which gives you nothing at all for flips, rotations, locks or
reveals — those have no semantic delegate. Use the semantic delegate when only one action matters; use onAction
when "something was done to this piece" is the rule.
Example
// content/scripting-api/examples/objecthandle.onAction.ts
// Object script: react to what happens to this one entity. The action is an
// ObservedObjectAction - the engine's full 19-name vocabulary, not the 13 a
// script may request - so handle the extras in a default branch.
let flips = 0;
refObject.onAction.add((entity, action, context) => {
switch (action) {
case "flip":
flips += 1;
world.log(`${entity.name ?? entity.id} flipped ${flips}x; faceUp is now ${String(entity.faceUp)}.`);
if (flips >= 3) {
world.broadcast(`${entity.name ?? entity.id} has been flipped ${flips} times - locking it.`);
entity.lock();
}
break;
case "lock":
world.log(`${context.actor} locked ${entity.id}; further actions are refused until unlock.`);
break;
case "delete":
world.log(`${context.actor} is removing ${entity.id}; onDestroyed fires next.`);
break;
default:
world.log(`${context.actor} performed "${action}" on ${entity.id}.`);
break;
}
});
world.log(`Action watcher is attached to ${refObject.id}.`);
Flipping the card three times prints three … flipped Nx; faceUp is now … lines, then puts
ace-of-spades has been flipped 3 times - locking it. into the table chat and immediately logs the resulting
lock action.
Gotchas
The action you receive is not the action you can pass back. The argument is an ObservedObjectAction, which
includes lift, flick, press and the three reveal-* names that no script may request, so handing it to a
helper that requests an action does not compile. Narrow first, and keep a default branch — the engine's list and the
declared union live in two hand-maintained files (packages/shared/src/tableObjects.ts,
packages/shared/src/scripting.ts), so a never check is a promise about a list this script does not own. See
ObservedObjectAction.
A mutator called from this handler re-enters it. entity.lock() inside an onAction handler produces a
lock action, which raises onAction again with actor: "Script". The example is safe because the lock
branch does not act; guard on context.actor === "Script" for anything that could recurse.
Locking restricts players, not scripts. A locked entity refuses player actions, and the host — including every table script — bypasses that gate entirely.
For delete the handle addresses nothing. Mutators called on it do nothing, and onDestroyed fires next.
See also
globalEvents.onObjectAction— the table-wide twin.ObservedObjectAction— the 19 names this delegate can deliver.ObjectAction— the 13 a script may request, and the 4 with no method.- Action vocabularies — 19 engine actions, 13 a script may request, 10 a mod may.
ObjectHandle.onDestroyed— what follows adelete.- Events and delegates — the per-action ordering table.
objecthandle.onRolled#
readonly onRolled: ScriptDelegate<[ObjectHandle, number | null, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when this die settles after a roll. value is the face value when known.
Fires when the die this handle addresses comes to rest after tumbling, carrying the number printed on the
face it settled on. It is the entity-scoped twin of
globalEvents.onDiceRolled and fires immediately after
it — both roughly a second after the throw that started the tumble, not at the moment the impulse was applied.
Parameters
The handler receives three arguments, transcribed from
ScriptDelegate<[ObjectHandle, number | null, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The same handle you subscribed on, refilled from the event's state — the pose is the settled pose, because the die has already stopped. |
| 2 | number | null |
The printed face value, verbatim. null means no readable value: the die is cocked, or it is a custom model with no face table. |
| 3 | EventContext |
"Script", "Host", "You", or a peer id. A die that tumbled to rest with no roll action behind it — a physical shake-throw, or a die knocked hard enough to spin — is attributed to "Host", because no actor is knowable. |
Applies to: die. The number comes from the die's face table, so nothing else can raise this by settling. A
card or board given roll() is still launched with the same impulse and still raises
onAction/onObjectAction, but it has no faces to read and never reaches this delegate.
How, why and when to use it
One die at the table is the die — the initiative die, the timer die — and only its result matters. Subscribing
on its handle means the handler never has to check which die was thrown, which is exactly the check that goes
wrong when a second die is added later. The alternative is globalEvents.onDiceRolled plus an id comparison, and
that is the right choice when any die can be thrown and you are scoring all of them. Put the rule on the handle
when the die is a fixture of the game; put it on globalEvents when dice come and go.
Because the event now arrives on the settle rather than on the throw, this is also the moment to read the die's
pose: there is no tumble left to wait out and no world.wait to guess at.
Example
// content/scripting-api/examples/objecthandle.onRolled.ts
// Object script on a die: keep a running tally of what this one die lands on.
// The event fires when the die comes to REST - roughly a second after the
// throw - and `value` is the number printed on the face it settled on.
let throws = 0;
let total = 0;
refObject.onRolled.add((die, value, context) => {
throws += 1;
if (value === null) {
// Cocked: leaning on a piece or wedged against something, so no face is
// squarely upright. Nothing readable happened - do not invent a number.
world.broadcast(`${die.name ?? die.id} landed cocked. Roll it again.`);
return;
}
total += value;
world.log(`${context.actor} rolled ${value} (throw ${throws}, total ${total}).`);
if (value === 20) {
world.broadcast(`${context.actor} rolled a natural 20!`);
}
});
world.log(`Roll tally attached to ${refObject.id}.`);
Rolling the die prints You rolled 14 (throw 1, total 14). about a second after the throw lands.
Gotchas
"Rolled" now means the die has stopped, not that the throw was applied. This changed: the event used to fire
from the roll action at the instant of the impulse, and value was a literal null every time. It now fires
on the settle. A handler that assumed it ran beside onAction runs about a second later than it used to, and
onAction no longer implies this delegate is about to fire.
A nudge is not a roll. A die has to actually spin to raise this. Bumping one across the table updates the face it is showing and raises nothing here — which is what stops a busy table from scoring a round every time somebody pushes a die aside.
A roll nobody asked for still fires. A die shaken by hand or knocked hard enough to tumble settles into this
event with context.actor === "Host". Do not treat the event as proof that a script or a player called roll().
null means unreadable, not unimplemented. Two cases produce it: a cocked die (leaning past half the angle
between two of its faces, where the winning face would be a coin toss between neighbours) and a die imported as
a custom model, which carries no face table at all. Both are honest "no answer" results; ask for a re-roll
rather than substituting a number.
onAction with "roll" fires first on this same handle, and much earlier. The full order for one roll is
globalEvents.onObjectAction and ObjectHandle.onAction at the throw, then — once the die stops —
globalEvents.onDiceRolled and this.
Physics has to be running. A throw with the simulation frozen goes nowhere, so nothing ever settles and this never fires. Edit Mode switches from Frozen to Live when you press ▶ Play Scripts.
See also
globalEvents.onDiceRolled— the table-wide twin.ObjectHandle.roll— the call that raises this from a script.ObjectHandle.refresh— reading the settled pose back.tableObjectDefinitionSchema.faceValue— the same number as it rides in the snapshot.- Events and delegates — the per-action ordering table.
objecthandle.onCardDrawn#
readonly onCardDrawn: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires on a CONTAINER when a card is drawn from it: the handle argument is
the newly created card, and context.containerId is this container's id.
Fires on a container when a card is drawn from it. It is the entity-scoped twin of
globalEvents.onCardDrawn, and it is the one entity-scoped
delegate whose subject is not the event's entity: the cardDrawn event names the newly created card, and the
sandbox deliberately routes the delegate to the deck or bag named by context.containerId
(apps/web/src/scripting/sandbox/tableScriptSandbox.html, routeLifecycleEvent) — because deck.onCardDrawn
is what an author means.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The drawn card, not the container. The container is the handle you subscribed on. |
| 2 | EventContext |
context.containerId is this container's id — the value the routing was done by — and context.actor is whoever drew. |
Applies to: deck and bag. Subscribing on any other kind is legal and the delegate never fires, because
nothing else is drawn from.
How, why and when to use it
You are writing an object script on a deck and want to know when a card comes off this deck — a reshuffle after
five draws, a "the deck is running low" warning, a per-deck draw log. This delegate is that, with no filtering:
the routing has already narrowed the event to your container. The alternative is
globalEvents.onCardDrawn plus a context.containerId comparison, which is the right shape when one handler has
to serve several decks or when the script is a scene script with no refObject. Use the entity-scoped delegate
in an object script; use the table-wide one when you are watching more than one container.
refObject.onAction filtered to "draw" is a third option and answers a different question: it fires for a draw
that produced nothing, so it counts attempts rather than cards.
Example
// content/scripting-api/examples/objecthandle.onCardDrawn.ts
// Object script on a deck: the entity-scoped delegate is routed to the
// CONTAINER, so it fires for every card drawn off this deck and for no other.
// The handle argument is the new card; refObject is still the deck.
let drawn = 0;
refObject.onCardDrawn.add((card, context) => {
drawn += 1;
world.log(`${context.actor} drew ${card.name ?? card.id} from ${refObject.id} (${drawn} so far).`);
if (drawn >= 5) {
world.broadcast(`Five cards are out of ${refObject.name ?? refObject.id} - shuffling the rest.`);
refObject.shuffle();
drawn = 0;
}
});
refObject.onShuffled.add(() => {
world.log(`${refObject.id} was shuffled; the draw counter stays at ${drawn}.`);
});
world.log(`Draw counter is attached to deck ${refObject.id}.`);
Each draw prints a1b2c3d4 drew ace-of-spades from obj-2 (1 so far).; the fifth puts a line in the table chat
and shuffles what is left.
Gotchas
The sandbox has to know the container's handle for this to fire. Routing looks the container up in the
sandbox's handle cache, so a container no script has ever taken a handle to receives nothing — which is harmless,
because nothing can have subscribed to it either. In an object script refObject is that handle, so this is only
a consideration if you build handles dynamically.
The argument order surprises people. The handle you get is the card; the handle you subscribed on is the
container. Reach for refObject when you mean the deck.
A draw can consume the deck. Drawing the last card removes the container, and drawing it down to one card
converts the remainder into a plain card and removes the deck entity. Either way onDestroyed fires on this
handle after the draw, and the sandbox then drops the handle.
Nothing fires for a card produced by a split. A split is not a draw: it raises onObjectCreated and
onObjectAction, and no cardDrawn. If your game splits decks, count both.
See also
globalEvents.onCardDrawn— the table-wide twin, for watching several containers.EventContext.containerId— the field this delegate is routed by.ObjectHandle.onAction— the deck-scoped alternative, counting"draw"attempts.ObjectHandle.draw— asking the container for a card.- Events and delegates — the per-action ordering table.
objecthandle.onShuffled#
readonly onShuffled: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a shuffle action is applied to the entity this handle addresses. It is the entity-scoped twin of
globalEvents.onContainerShuffled and fires
immediately after it, once the contents have already been reordered with a host-private seed.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The same handle you subscribed on, refilled from the event's state. |
| 2 | EventContext |
"Script" for a shuffle() call, "You" for a menu click or a shake gesture at the host machine, or the peer id of the player who shuffled. |
Applies to: deck, and nothing else. Only a deck has its card entries reshuffled, and a deck with fewer
than two cards is left alone (the delegate still fires — there was nothing to reorder, but the shuffle happened).
A shuffle addressed to any other kind is refused before anything runs, so this delegate never fires for one.
Attaching it to a bag is dead code: a bag draws at random and has no order to shuffle.
How, why and when to use it
The draw pile in your game has a rule of its own — reshuffle the discard back in, announce the count, refuse to
be shuffled twice in a turn. Attaching an object script to that deck means the handler is the rule and there is
no id to compare. The alternative, globalEvents.onContainerShuffled with a filter, is what you want when
several decks share one rule or when decks are created during play. Put it on the handle when the deck is a
fixture; put it on globalEvents when containers come and go.
Example
// content/scripting-api/examples/objecthandle.onShuffled.ts
// Object script on a deck: react when this container is reordered. Attaching
// to the deck means no id comparison - the delegate only fires for it.
let shuffles = 0;
refObject.onShuffled.add((deck, context) => {
shuffles += 1;
world.broadcast(`${deck.name ?? deck.kind} shuffled by ${context.actor} (${shuffles}x).`);
void recordShuffle();
});
async function recordShuffle(): Promise<void> {
await world.setSavedData(String(shuffles), "shuffles");
const stored = await world.getSavedData("shuffles");
world.log(`Shuffle count stored for this table: ${stored ?? "0"}.`);
}
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!shuffle") {
refObject.shuffle();
world.log(`${message.displayName ?? message.peerId} asked for a shuffle.`);
}
});
world.log(`Shuffle watcher is attached to ${refObject.id}.`);
Typing !shuffle puts draw-pile shuffled by Script (1x). into the table chat and prints
Shuffle count stored for this table: 1. to the script console.
Gotchas
Calling shuffle() from inside the handler recurses. The example's chat handler is safe because it is a
different delegate; a refObject.shuffle() inside onShuffled would raise the event again with
actor: "Script" and never stop. Guard on the actor if you need to reshuffle from here.
The resulting order is host-private and unreadable from a script. There is no method that lists a
container's contents. Track what you need from
globalEvents.onCardDrawn instead.
A shake gesture counts. Shaking a deck at the table applies the same action, so the event fires for gestures as well as menu clicks and script calls.
onAction with "shuffle" fires first on this same handle, after the table-wide pair.
See also
globalEvents.onContainerShuffled— the table-wide twin.ObjectHandle.shuffle— the call that raises this from a script.world.setSavedData— persisting a count across a save.- Action vocabularies — what
shuffledoes on each kind. - Events and delegates — the per-action ordering table.
objecthandle.onMenuItem#
readonly onMenuItem: ScriptDelegate<[ObjectHandle, string, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A player clicked one of this script's menu entries ON THIS ENTITY. The string is your own
item id. The item still has to match this entity to be offered at all.
The object-scoped twin of
globalEvents.onObjectMenuItem: fires only when the
clicked entry was on this entity. In an object script that is almost always what you want — refObject is
the piece the script is attached to, so there is nothing left to filter.
Parameters
| Parameter | Type | Notes |
|---|---|---|
object |
ObjectHandle |
This entity. |
itemId |
string |
Your id, as registered. |
context |
EventContext |
context.actor is the peer who clicked. |
How, why and when to use it
Use it in an object script when the verb belongs to one particular piece. Use the global delegate in a scene script when one handler serves a whole class of entities — a rule about every pawn is one handler on the scene, not sixteen identical object scripts.
Example
// content/scripting-api/examples/objecthandle.onMenuItem.ts
// Object script on a single entity: the menu entry is registered globally, but
// narrowed to THIS entity, and the callback is narrowed to it as well.
//
// `match: { objectIds: [refObject.id] }` is the narrowest filter there is - one
// entity, by id. Registration is always a `world` call; only the CALLBACK is
// object-scoped.
world.addObjectMenuItem({
id: "rally",
label: "Rally to this banner",
match: { objectIds: [refObject.id] }
});
// Fires only when the clicked entry was on this entity, so there is nothing left
// to filter - in an object script that is almost always what you want.
refObject.onMenuItem.add((object, itemId, context) => {
if (itemId !== "rally") {
return;
}
world.broadcast(`${context.actor} rallies to ${object.name ?? "the banner"}.`);
});
See world.addObjectMenuItem for the
registration side of the same pair.
Gotchas
Registering is still global. There is no per-entity addObjectMenuItem: you register the entry on world
with a match that selects this entity (match: { objectIds: [refObject.id] } is the narrowest form), and this
delegate then narrows the callback to it.
The entry still has to match this entity to be offered at all. This delegate cannot surface an item whose
filter excludes the entity it is attached to — if nothing appears in the menu, the filter is where to look, not
the handler.
See also
world.addObjectMenuItem— register the entry.globalEvents.onObjectMenuItem— every click, for every matching entity.
objecthandle.refresh#
refresh(): Promise<ObjectData | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Refresh and return this object's latest state (null if it no longer exists).
Asks the host for this entity's current state, updates the handle's own properties from the answer, and resolves
with a plain copy of them. This is how a handle stops being stale — everything on ObjectData is a cached value
until something refreshes it.
Returns
Promise<ObjectData | null>.
null means the host's current snapshot has no object with this handle's id. The entity was destroyed, or the
world.spawnObject that produced this handle was rejected by the host. A null answer leaves the handle's cached
properties exactly as they were — they are the last state you saw, not a cleared record — so do not read
handle.position after a null refresh and expect anything meaningful.
A non-null answer is a plain data copy, not the handle. It has the ten ObjectData fields and nothing
else: no methods, no event delegates. The handle you called it on is updated in place at the same time, so
handle.faceUp and result.faceUp agree immediately afterwards.
How, why and when to use it
You called handle.flip() and want to know which way the card ended up. The mutators return void immediately —
they post an intent and do not wait — so the handle you are holding still reports the pre-flip state.
refresh() is the read that closes that loop, and because the sandbox delivers the intent and the read to the
host in order, a refresh() issued after a mutator always sees that mutator applied. The alternative is
world.getObjectById(handle.id), which costs the same round trip and hands back the very same handle — so it
tells you nothing extra and reads worse. Use refresh() after any mutation whose result you need, and after any
await, because the table moved while you were suspended.
Example
// content/scripting-api/examples/objecthandle.refresh.ts
// Scene script: turn every card face down, then confirm each one changed.
// Mutators return immediately, so refresh() is how you read the result back.
async function faceEverythingDown(): Promise<void> {
const cards = await world.getAllObjects({ kind: "card" });
if (cards.length === 0) {
world.log("No cards on the table.");
return;
}
for (const card of cards) {
if (card.faceUp === false) {
continue;
}
card.flip();
const after = await card.refresh();
if (after === null) {
world.log(`${card.id} is gone - refresh found no such entity.`);
continue;
}
world.log(`${after.id} faceUp is now ${String(after.faceUp)}.`);
}
}
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!facedown") {
void faceEverythingDown();
}
});
void faceEverythingDown();
With two face-up cards on the table the script console prints two lines like
script-1f2e3d4c5b6a7089 faceUp is now false.
Gotchas
Resolves after a round-trip to the host, so the value is the host's state at the moment it answered — not necessarily still true when your handler continues. A player can pick the entity up on the next line.
One refresh pulls the whole snapshot across the sandbox boundary. Refreshing fifty handles in a loop is fifty
serializations of the entire table. When you need many, call world.getAllObjects once — it refreshes every
handle it returns in a single round trip.
A null does not invalidate the handle. The JavaScript object stays usable and its cached fields keep their
last values, so a stale handle.id will keep addressing a deleted entity. Drop your reference when a refresh
returns null.
See also
ObjectData— the ten fields a refresh updates.world.getAllObjects— refreshing many handles at once.world.getObjectById— the same round trip when you have only an id.- Async and snapshots — why a handle is a cache and not a live view.
objecthandle.setPosition#
setPosition(position: Vec3): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Teleports this entity to an absolute point in table space. The sandbox posts a transform intent carrying the
position; the host validates it, moves the rigidbody there and wakes it.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
position |
Vec3 |
yes | [x, y, z] in feet, absolute table-space coordinates — not an offset from where the entity is now. [0, 1, 0] is one foot above the table origin. Individual entries that are not finite numbers become 0, so [NaN, 1, 0] puts the entity on the center line. A value that is not an array of at least three entries is dropped before any intent is sent: nothing moves, nothing is logged, and the call still returns normally. |
Applicability: every object kind. Nothing about the move changes with kind.
How, why and when to use it
A card has just been drawn and you want it in the middle of the table rather than beside the deck the host
dropped it next to. setPosition is the only way a script can place an entity at a coordinate it chose. The
alternative is to spawn things where you want them in the first place — world.spawnObject takes a position,
and for anything your script creates that is the better answer, because it costs one intent instead of two and
never shows the entity in the wrong place first. Use setPosition when the entity already exists and something
that just happened decided where it belongs: a drawn card, a turn marker, a piece returning to its start square.
Example
// content/scripting-api/examples/objecthandle.setPosition.ts
// Scene script: line the table's dice up in a row on demand. Coordinates are
// absolute table-space FEET, and setPosition is a teleport, not a nudge.
const ROW_Z = -2;
const SPACING = 1.25;
const REST_HEIGHT = 0.6;
async function lineUpDice(): Promise<void> {
const dice = await world.getAllObjects({ kind: "die" });
if (dice.length === 0) {
world.log("No dice on the table to line up.");
return;
}
const firstX = -((dice.length - 1) * SPACING) / 2;
dice.forEach((die, index) => {
die.setPosition([firstX + index * SPACING, REST_HEIGHT, ROW_Z]);
});
world.log(`Lined up ${dice.length} dice along z = ${ROW_Z} ft.`);
// The move is not readable on the handle until the host answers, so ask.
const settled = await dice[0].refresh();
world.log(settled === null
? "The first die vanished before it could be read back."
: `First die is now at [${settled.position.join(", ")}] ft.`);
}
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!lineup") {
void lineUpDice();
}
});
void lineUpDice();
With three dice on the table, the script console prints Lined up 3 dice along z = -2 ft. then
First die is now at [-1.25, 0.6, -2] ft.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result, and remember other
players only see the move on the host's next snapshot.
It is a teleport, not a constraint. The host moves the rigidbody and then re-activates it, so physics takes
over from the new pose: a dynamic entity placed above the surface falls to it, and one placed inside another is
pushed out. Give the height you want it to come to rest at, or lock() it first.
A malformed position is dropped in silence. The sandbox never sends a transform intent it cannot build a
vector from, and it does not report that it declined. If a position is coming from data rather than a literal,
validate it yourself — a missing third entry looks exactly like a call that did nothing.
A locked entity still moves. The per-kind and locked-state gate (packages/shared/src/tableObjects.ts,
isObjectActionAllowedForTarget) runs on intents arriving from a player or spectator. A script's intents are
raised on the host and do not pass through it, so lock() protects an entity from players, not from your script.
See also
ObjectHandle.setRotation— the other half of a transform.ObjectData.position— reading the current point.world.spawnObject— placing an entity when you create it.- Moving, rotating and scaling entities — the same coordinates in Edit Mode.
- Host authority — why a move is a request that the host applies.
objecthandle.setRotation#
setRotation(rotation: Vec3): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Turns this entity to an absolute orientation. The sandbox posts a transform intent carrying the Euler angles;
the host keeps the entity where it is and re-seats it at the new rotation.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
rotation |
Vec3 |
yes | [x, y, z] Euler angles in degrees, absolute — not a delta from the current orientation. [0, 90, 0] is a quarter turn about the vertical axis from the table's zero, whatever the entity was doing before. Entries that are not finite numbers become 0. A value that is not an array of at least three entries is dropped before any intent is sent: nothing turns and nothing is logged. |
Applicability: every object kind.
How, why and when to use it
A board has been dragged around all game and is sitting at 37°, and you want it square to the table again.
setRotation sets the angle you name. The alternative is rotate(), which most authors try first because it is
shorter — but it applies a fixed 90° step to whatever the entity is currently at, so from 37° it takes you to
127°, not to 90°. Use setRotation when you know the orientation you want; use rotate() when you want the same
quarter-turn a player would get from the object menu.
Example
// content/scripting-api/examples/objecthandle.setRotation.ts
// Scene script: square every board up to the table axes. Rotation is absolute
// Euler degrees, so this snaps to an angle rather than stepping by one.
function nearestRightAngle(degrees: number): number {
const snapped = Math.round(degrees / 90) * 90;
return ((snapped % 360) + 360) % 360;
}
async function squareUpBoards(): Promise<void> {
const boards = await world.getAllObjects({ kind: "board" });
if (boards.length === 0) {
world.log("No boards on the table.");
return;
}
for (const board of boards) {
const yaw = nearestRightAngle(board.rotation[1]);
board.setRotation([0, yaw, 0]);
const after = await board.refresh();
world.log(after === null
? `${board.id} vanished before it could be read back.`
: `${after.id} squared up to yaw ${nearestRightAngle(after.rotation[1])} degrees.`);
}
}
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!square") {
void squareUpBoards();
}
});
void squareUpBoards();
With one board sitting at 37° the script console prints
script-1f2e3d4c5b6a7089 squared up to yaw 0 degrees.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result, and remember other
players only see the turn on the host's next snapshot.
Rotating a card 180° does not flip it. faceUp is a separate flag on the entity, and the transform path never
touches it: a card turned to [180, 0, 0] faces the other way and still reports the face state it had. Only
flip() changes faceUp, and it does both at once. Never infer face state from rotation.
A malformed rotation is dropped in silence, exactly like setPosition. Validate data-driven angles yourself.
Angles are not normalized. [0, 450, 0] is accepted and the host applies it; the entity ends up where 90°
would have put it, but what you read back afterwards is whatever the engine reports for its world orientation, not
the number you sent. Normalize before you compare.
See also
ObjectHandle.rotate— the fixed 90° step.ObjectHandle.flip— the one that changesfaceUp.ObjectData.rotation— reading the current angles.- Moving, rotating and scaling entities — the same angles in Edit Mode.
objecthandle.flip#
flip(): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Turns this entity over. The sandbox posts an object-action intent with the action flip; the host toggles the
entity's face state and rotates it 180° about its own X axis.
How, why and when to use it
A round has ended and every card in play should be face up so scores can be read. flip() is the action that
turns one over, and it is a toggle, so a scoring pass checks faceUp first and only calls it on the cards that
need it. The alternative is setRotation, which will physically turn a card over and looks right — and is wrong,
because it does not change faceUp, so the hidden-information system still treats the card as face down and
peers may not be shown its face at all. Use flip() whenever the face matters; use setRotation only for
orientation that has nothing to do with which side is showing.
Example
// content/scripting-api/examples/objecthandle.flip.ts
// Scene script: turn every card face up at the end of a round. flip is a
// toggle, so read faceUp first rather than calling it blindly.
async function revealAllCards(): Promise<void> {
const cards = await world.getAllObjects({ kind: "card" });
if (cards.length === 0) {
world.log("No cards in play.");
return;
}
let flipped = 0;
for (const card of cards) {
if (card.faceUp === true) {
continue;
}
card.flip();
flipped += 1;
}
world.log(`Flipped ${flipped} of ${cards.length} cards.`);
if (flipped > 0) {
world.broadcast(`${flipped} card(s) turned face up.`);
}
}
globalEvents.onTurnEnded.add(() => {
void revealAllCards();
});
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!reveal") {
void revealAllCards();
}
});
With four cards down and one already up, the script console prints Flipped 4 of 5 cards. and every player's
chat shows 4 card(s) turned face up.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result.
Applies to: every object kind, and the host does not check. On a card it turns the card over. On a deck it
turns the whole stack over and flips every card entry inside it, so the deck's order is preserved but every
entry's face state inverts. On a die, token, board, bag, custom or card-holder the host still toggles
the face flag and rotates the entity 180° — you get an upside-down die reporting faceUp: false, which is almost
never what you meant. Read handle.kind before flipping anything your script did not spawn.
Flipping is not revealing. flip() changes which way the entity is facing; it does not touch the reveal state
the hidden-information system uses to decide which peers may see a card's face. Scripts cannot request the
reveal-* actions at all — see Action vocabularies for why that boundary
exists and what to model instead.
Two flips in the same handler cancel out. Each call is a separate intent and the host applies both, so a double toggle is a visible no-op with two snapshots' cost.
See also
ObjectData.faceUp— the flag this toggles.ObjectHandle.setRotation— orientation without face state.- Action vocabularies — the 19 engine actions, the 13 a script may request,
and why
reveal-*is not one of them. - Object kinds — what each kind is and which ones have a face.
objecthandle.rotate#
rotate(): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Turns this entity a quarter turn. The sandbox posts an object-action intent with the action rotate; the host
rotates the entity 90° about its own vertical axis. It is a step, not an angle — there is nothing to pass.
How, why and when to use it
A tile has to face the next player each time the turn moves, and the table is laid out so that "the next player"
is always 90° round. rotate() is exactly the step a player gets from the object menu, so a script using it
produces motion that looks like something a person did. The alternative is setRotation, which is the right tool
the moment you know the absolute angle you want — rotate() composes with whatever the entity is currently at,
so on an entity a player has nudged you end up 90° from a random starting point rather than at a clean multiple.
Use rotate() for repeated quarter turns from wherever the entity is; use setRotation to snap to an angle.
Example
// content/scripting-api/examples/objecthandle.rotate.ts
// Scene script: give every token a quarter turn when a round starts. rotate is
// a fixed 90-degree step; setRotation is the one that takes an angle.
async function quarterTurnTokens(): Promise<void> {
const tokens = await world.getAllObjects({ kind: "token" });
if (tokens.length === 0) {
world.log("No tokens on the table.");
return;
}
const yawBefore = tokens[0].rotation[1];
for (const token of tokens) {
token.rotate();
}
const after = await tokens[0].refresh();
if (after === null) {
world.log(`Turned ${tokens.length} token(s); the first one has left the table.`);
return;
}
world.log(`Turned ${tokens.length} token(s). First yaw ${yawBefore} -> ${after.rotation[1]} degrees.`);
}
globalEvents.onTurnStarted.add(() => {
void quarterTurnTokens();
});
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!turn") {
void quarterTurnTokens();
}
});
With two tokens on the table the script console prints
Turned 2 token(s). First yaw 0 -> 90 degrees.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result.
Applies to: every object kind. The host turns whatever you address, including a board or a deck. Nothing about the step varies by kind, and nothing about it is refused.
The step is about the entity's own axis, not the table's. On an entity that is already tilted, four calls do
not necessarily return it to where it started in world terms. When you need a guaranteed orientation, name it with
setRotation.
It does not change the face. A card rotated four times is back where it started and has never changed
faceUp. Use flip() for that.
See also
ObjectHandle.setRotation— the absolute-angle version.ObjectHandle.flip— the turn that changesfaceUp.- Action vocabularies — what the host does for each of the 23 actions.
- Moving, rotating and scaling entities — the same rotation by hand.
objecthandle.lock#
lock(): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Pins this entity in place. The sandbox posts an object-action intent with the action lock; the host marks the
entity locked and switches its rigidbody to static, so physics stops moving it and players stop being able to.
How, why and when to use it
The board is the thing everything else sits on, and a player who grabs the wrong pixel drags the whole play area out from under the pieces. Locking it at load removes that failure mode for the rest of the session. The alternative most authors reach for is authoring the board with a static rigidbody in Edit Mode, which stops physics pushing it but leaves it draggable — locking is what actually stops the hand. Lock anything a player should not be able to move: boards, backgrounds, seat markers, a deck that must stay where you put it. Leave everything a player is meant to pick up unlocked, because a locked entity refuses almost everything they can ask of it.
Example
// content/scripting-api/examples/objecthandle.lock.ts
// Scene script: pin the boards down at load and keep new ones pinned, so a
// stray drag cannot slide the play area out from under everything else.
async function lockBoards(): Promise<void> {
const boards = await world.getAllObjects({ kind: "board" });
if (boards.length === 0) {
world.log("No boards to lock.");
return;
}
let locked = 0;
for (const board of boards) {
if (board.locked) {
continue;
}
board.lock();
locked += 1;
}
world.log(`Locked ${locked} of ${boards.length} board(s).`);
const first = await boards[0].refresh();
if (first !== null) {
world.log(`${first.id} locked = ${String(first.locked)}.`);
}
}
globalEvents.onObjectCreated.add((handle) => {
if (handle.kind === "board" && !handle.locked) {
handle.lock();
world.log(`Locked newly created board ${handle.id}.`);
}
});
void lockBoards();
With one unlocked board the script console prints Locked 1 of 1 board(s). then
script-1f2e3d4c5b6a7089 locked = true.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result.
Applies to: every object kind. Any entity can be locked, and locking one that is already locked is a no-op
that still costs an intent and a snapshot — check handle.locked first, as the example does.
A lock stops players, not scripts. The gate that refuses actions on a locked entity
(packages/shared/src/tableObjects.ts, isObjectActionAllowedForTarget) runs on intents arriving from a player
or spectator. Intents raised on the host — your script, a mod running on the host, the host's own UI — do not pass
through it. Your script can still flip, move and delete an entity it just locked.
Locking changes the physics body, not only a flag. The rigidbody becomes static, so the entity stops falling,
stops being pushed and stops pushing back. unlock() restores the body type it was authored with, which is not
necessarily dynamic.
See also
ObjectHandle.unlock— the only action a locked entity accepts from a player.ObjectData.locked— reading the current state.- Action vocabularies — the per-kind gate and the two rules above it.
- RIGIDBODY — the body type a lock temporarily replaces.
objecthandle.unlock#
unlock(): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Releases this entity. The sandbox posts an object-action intent with the action unlock; the host clears the
locked flag and restores the rigidbody type the entity was authored with, so physics and players can move it
again.
How, why and when to use it
The game is over and every piece your script pinned down during play should go back to being draggable, because
the next thing anybody wants to do is push the board around and put things away. unlock() is the release.
The alternative is to leave the table locked and let players unlock pieces one at a time from the object menu —
they can, because unlock is the one action a locked entity still accepts from a player — but that is a chore you
can spare them. Unlock in bulk at the end of a game or a phase; leave individual unlocking to players when the
lock is theirs rather than yours.
Example
// content/scripting-api/examples/objecthandle.unlock.ts
// Scene script: release every locked entity when the game is over, so players
// can tidy up by hand. unlock is the one action a locked entity still accepts.
async function unlockEverything(): Promise<void> {
const everything = await world.getAllObjects();
const locked = everything.filter((handle) => handle.locked);
if (locked.length === 0) {
world.log("Nothing on the table is locked.");
return;
}
for (const handle of locked) {
handle.unlock();
world.log(`Unlocked ${handle.kind} ${handle.id}.`);
}
world.broadcast(`${locked.length} piece(s) unlocked - the table is free to rearrange.`);
const check = await locked[0].refresh();
if (check !== null) {
world.log(`${check.id} locked = ${String(check.locked)}.`);
}
}
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!unlockall") {
void unlockEverything();
}
});
With two locked entities the script console prints one Unlocked … line each, then
script-1f2e3d4c5b6a7089 locked = false., and every player's chat shows
2 piece(s) unlocked - the table is free to rearrange.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result.
Applies to: every object kind. Unlocking an entity that is not locked is a no-op that still costs an intent
and a snapshot — filter on handle.locked first, as the example does.
Unlocking restores the authored body type, not "dynamic". A board authored as a static rigidbody is static again after the unlock; it has stopped refusing player actions and nothing else. If you expected it to start falling, the lock was never what was holding it up.
The entity may resume moving immediately. An unlocked dynamic entity that was locked in mid-air falls the moment the host applies this. Place it where it should rest before you release it.
See also
ObjectHandle.lock— the other half.ObjectData.locked— reading the current state.- Action vocabularies — why
unlockis the exception to the locked rule. - RIGIDBODY — the authored body type this restores.
objecthandle.roll#
roll(): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Roll this object (dice).
Throws this die. The sandbox posts an object-action intent with the action roll; the host applies a randomized
impulse and lets the physics simulation decide where it lands and which face is up.
How, why and when to use it
A round starts and the game rolls for everyone rather than asking each player to throw their own die.
roll() gives you the same throw a player gets — a real physics tumble, visible to everyone as it happens, and
unpredictable in the way a die is supposed to be. The alternative is to compute a number with Math.random() and
put it somewhere, which is faster and completely deterministic to implement — and looks like nothing at the table,
because no die moves. Roll when the throw is part of the game people are watching; generate a number yourself when
you need a random value that no player is supposed to see land.
Example
// content/scripting-api/examples/objecthandle.roll.ts
// Scene script: roll every die on the table on request. roll() applies a
// randomized throw impulse and returns straight away; each die reports what
// it landed on through onDiceRolled, about a second later, when it stops.
async function rollAllDice(): Promise<void> {
const dice = await world.getAllObjects({ kind: "die" });
if (dice.length === 0) {
world.log("No dice on the table.");
return;
}
for (const die of dice) {
die.roll();
}
world.broadcast(`Rolling ${dice.length} dice...`);
}
globalEvents.onDiceRolled.add((handle, value) => {
world.log(value === null
? `${handle.id} landed cocked; no value to read.`
: `${handle.id} settled showing ${value}.`);
});
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!roll") {
void rollAllDice();
}
});
With two dice the chat shows Rolling 2 dice..., and about a second later the script console prints one
obj-1 settled showing 4. line per die as each one stops.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it, and the die keeps moving long after that. Do not poll for the result: subscribe to
globalEvents.onDiceRolled (or onRolled on the die's own
handle), which fires when the die stops and hands you the number it landed on.
Applies to: die. The host does not check, and every other kind is thrown anyway. There is no guard on the
action — roll a card and the host launches the card across the table with a die's impulse; roll a board and it
takes off too. Check handle.kind before rolling anything your script did not spawn.
The result arrives on the settle, about a second later. globalEvents.onDiceRolled and
ObjectHandle.onRolled no longer fire beside the roll action — they fire when the die comes to rest, carrying
the printed face value. A die that lands cocked, and a die imported as a custom model with no face table, report
value: null; that is "no readable value", not "not implemented".
Physics has to be running. A throw with the simulation frozen goes nowhere. Edit Mode switches from Frozen to Live automatically when you press ▶ Play Scripts, and says so in the script console.
Rolling several dice in one loop is one impulse each, not a shared throw. They will collide with each other if they are close together, which is realistic and occasionally sends one off the table — the host nudges strays back onto the surface, but a die that lands on another die stays there.
See also
globalEvents.onDiceRolled— the number the die landed on, once it stops.ObjectData.position— where the die came to rest.- Action vocabularies — what the host does for
rollon each kind. - Object kinds — the
diekind and its physics defaults.
objecthandle.shuffle#
shuffle(options?: ObjectActionOptions): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Shuffle this container/deck. Pass { silent: true } to reorder without the spin or the riffle — for setup a player is not meant to be watching.
Reorders this deck's contents. The sandbox posts an object-action intent with the action shuffle; the host
shuffles the card entries with a seed only it knows, then re-derives which card is showing.
How, why and when to use it
A hand has ended, the discards have been combined back into the deck, and the next deal must not be predictable
from the last one. shuffle() is the reorder, and the seed is host-private on purpose: no peer — including a
player reading their own network traffic — can reproduce the order or work out what is coming. The alternative is
to draw everything and spawn it back in a random order of your own, which costs one intent per card, loses the
deck's identity, and uses a random number generator every client can see. Shuffle when the deck should become
unknown; use draw and deal when you want cards to leave it.
Example
// content/scripting-api/examples/objecthandle.shuffle.ts
// Scene script: shuffle every deck at the start of a round. Only decks are
// queried - the host reorders a deck's contents and nothing else's.
async function shuffleDecks(): Promise<void> {
const decks = await world.getAllObjects({ kind: "deck" });
if (decks.length === 0) {
world.log("No decks on the table.");
return;
}
let shuffled = 0;
for (const deck of decks) {
const before = await deck.refresh();
if (before === null) {
world.log(`${deck.id} left the table before it could be shuffled.`);
continue;
}
deck.shuffle();
shuffled += 1;
world.log(`Shuffled deck ${deck.id} (label "${before.name ?? "none"}").`);
}
if (shuffled > 0) {
world.broadcast(`${shuffled} deck(s) shuffled.`);
}
}
globalEvents.onTurnStarted.add(() => {
void shuffleDecks();
});
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!shuffledecks") {
void shuffleDecks();
}
});
With one deck on the table the script console prints
Shuffled deck script-1f2e3d4c5b6a7089 (label "deck-standard"). and every player's chat shows
1 deck(s) shuffled.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result.
Applies to: deck, and only while it holds at least two cards. A deck of one card, or an empty one, is left
alone. On every other kind, including bag, the host refuses the action outright: nothing moves, no sound
plays, no onShuffled fires, and you get no error — the call resolves and nothing happened. If you are shuffling
a container, check that handle.kind is "deck" first. A bag draws at random, so there is no order for a
shuffle to change; if you wanted the visual, use handle.rotate().
The order is deliberately unknowable. The shuffle is seeded from host-private state, and peers learn the result only through the snapshot they are allowed to see. Your script runs on the host, so it could read the order back — do not build a game on that, because a player's client cannot, and any logic that depends on it will behave differently for the host than for everyone else.
A shuffle does not change what is on top by itself. The host re-derives the visible face from the new first entry, so the deck's appearance updates — but nothing about position, rotation or lock state changes.
See also
ObjectHandle.draw— taking one card off the container.ObjectHandle.deal— one card to every seat.- Action vocabularies — what the host does for
shuffleon each kind. - Deck Editor — building the deck this shuffles.
objecthandle.draw#
draw(): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Draw the top item from this deck/bag.
Takes one item off this container. The sandbox posts an object-action intent with the action draw; the host
removes the next entry according to the container's draw mode, spawns it as a new card entity, and shrinks the
container's stack.
How, why and when to use it
A player has landed on a space that says "draw a chance card", and the card should come off the deck without
anybody reaching for it. draw() is the take. The alternative is deal(), which most authors try when they want
cards to go to people — and which does something quite different: it gives one card to every seat, not one card
to the container's owner. Use draw() for a single card leaving a specific container; use deal() for a round of
cards going to the whole table.
Example
// content/scripting-api/examples/objecthandle.draw.ts
// Scene script: pull one card off each deck into the middle of the table. A
// script's draw is not attributed to a player, so place the card yourself.
const RIVER_Z = 0;
const RIVER_HEIGHT = 0.6;
async function drawToRiver(): Promise<void> {
const decks = await world.getAllObjects({ kind: "deck" });
if (decks.length === 0) {
world.log("No decks to draw from.");
return;
}
for (const deck of decks) {
deck.draw();
}
world.log(`Requested one card from each of ${decks.length} deck(s).`);
}
globalEvents.onCardDrawn.add((card, context) => {
world.log(`${context.actor} drew ${card.name ?? card.id}; moving it to the river.`);
card.setPosition([0, RIVER_HEIGHT, RIVER_Z]);
world.broadcast(`A card was turned to the river: ${card.name ?? "unnamed"}.`);
});
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!draw") {
void drawToRiver();
}
});
With one deck the script console prints Requested one card from each of 1 deck(s). then
Script drew AS; moving it to the river.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result, and note the drawn
card is a new entity with its own id, not something you can reach from this handle at all.
Applies to: deck and bag, and only while the container still holds something. The host returns without
doing anything for every other kind and for an empty container — there is no error and no log line, so a draw()
that appears to do nothing means one of exactly those two things: the wrong kind, or nothing left to take.
A script's draw is not attributed to a player. The host resolves the destination from the actor, and a
script's actor has no seat. The card therefore goes into a seat's hand only when the container itself is owned by
an active seat; otherwise it lands on the table about 0.9 ft to the +X side of the container. There is no
parameter for "draw to this player" — handle globalEvents.onCardDrawn, which fires with the newly created card
and the container's id, and place it yourself as the example does.
The container's draw mode decides which item comes off. A deck draws from its front by default; a bag can be
configured to draw at random. draw() takes no say in it.
See also
ObjectHandle.deal— one card to every authored seat.ObjectHandle.shuffle— making the next draw unknown.ObjectHandle.setPosition— placing the card the draw created.- Action vocabularies — what the host does for
drawon each kind. - Object kinds — the
deckandbagcontainer kinds.
objecthandle.deal#
deal(): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Deal cards from this deck to players.
Deals one item from this container to every seat. The sandbox posts an object-action intent with the action
deal; the host walks the table's active seat zones, takes one entry off the container for each and places it in
that seat's hand.
How, why and when to use it
The game is starting and everybody needs an opening hand. deal() is one pass around the table — one card to each
seat, in seat order, stopping if the container runs out. The alternative is draw() in a loop with a
setPosition per card, which is what you end up writing if you need to deal different numbers to different
seats — but for an even deal it is far more code and it puts the cards on the table rather than in hands. Use
deal() for even distribution to everyone; use draw() when one specific card is going to one specific place.
Example
// content/scripting-api/examples/objecthandle.deal.ts
// Scene script: deal an opening hand. Each deal() sends ONE card to every
// authored seat, so a five-card hand is five calls with a beat between them.
const OPENING_HAND = 5;
async function dealOpeningHands(): Promise<void> {
const decks = await world.getAllObjects({ kind: "deck" });
if (decks.length === 0) {
world.log("No deck to deal from.");
return;
}
const deck = decks[0];
world.broadcast("Dealing opening hands...");
for (let pass = 1; pass <= OPENING_HAND; pass += 1) {
const state = await deck.refresh();
if (state === null) {
world.log(`Deck ${deck.id} left the table mid-deal.`);
return;
}
deck.deal();
world.log(`Deal pass ${pass} of ${OPENING_HAND} sent from ${deck.id}.`);
await world.wait(0.4);
}
world.broadcast(`Opening hands dealt - ${OPENING_HAND} cards each.`);
}
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!deal") {
void dealOpeningHands();
}
});
With a deck and two seated players, chat shows Dealing opening hands..., the script console prints five
Deal pass N of 5 sent from … lines, and chat finishes with Opening hands dealt - 5 cards each.
Gotchas
This returns immediately. The change is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() if you need to read the result — and note each
dealt card is a new entity with its own id.
Applies to: deck and bag, and only while the container still holds something. Every other kind is ignored
without an error.
One call is one card per seat, not a whole hand. For a five-card opening hand you call it five times, as the example does. The pass stops early and silently when the container empties partway round, so the last seats can end up short.
The seats come from the table's authored seat zones, not from who is sitting in them. A table with no seat zones deals nothing at all — the host has nowhere to put a card — and a table whose zones are empty of players still deals into those empty hands. Author the seats before you rely on this.
Nothing about the deal is aimed. There is no parameter for a seat, a count, or a starting player. If your game
deals unevenly, or clockwise from the dealer, build it out of draw() and setPosition instead.
See also
ObjectHandle.draw— one card, one destination.ObjectHandle.shuffle— before the deal, not after.world.wait— pacing the passes so the table can follow them.- Action vocabularies — what the host does for
dealon each kind.
objecthandle.destroy#
destroy(): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | none |
| Availability | both |
Remove this object from the table.
Removes this entity from the table. The sandbox posts an object-action intent with the action delete — the
one action whose name does not match its method — and the host destroys the entity, re-parents any children it
had onto its own ancestor, and drops any joints attached to it.
How, why and when to use it
A round has ended and the cards played into the middle have to go, because next round starts from a clean table
and nobody wants to drag twenty cards into a corner. destroy() is how a script takes something off the table.
The alternative is to move the pieces somewhere out of the way with setPosition, which most authors do first
because it feels safer — and it is worse in the long run: every hidden entity still costs space in every snapshot
for the rest of the session and can still be found and dragged back. Destroy anything the game is genuinely
finished with; move things aside only when a player has a real reason to want them back.
Example
// content/scripting-api/examples/objecthandle.destroy.ts
// Scene script: clear the discard pile on command. destroy() sends the "delete"
// action - the one action whose name does not match its method.
const DISCARD_TAG = "discard";
async function clearDiscards(): Promise<void> {
const discards = await world.getAllObjects({ tag: DISCARD_TAG });
if (discards.length === 0) {
world.log(`Nothing tagged "${DISCARD_TAG}" to clear.`);
return;
}
for (const handle of discards) {
world.log(`Removing ${handle.kind} ${handle.id}.`);
handle.destroy();
}
const check = await discards[0].refresh();
world.log(check === null
? `Cleared ${discards.length} entity(ies).`
: `${check.id} is still on the table - the host refused the delete.`);
world.broadcast(`Cleared ${discards.length} discarded piece(s).`);
}
globalEvents.onObjectAction.add((handle, action) => {
if (action === "delete") {
world.log(`delete applied to ${handle.id}.`);
}
});
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!cleardiscard") {
void clearDiscards();
}
});
With three tagged cards the script console prints three Removing card … lines, three
delete applied to … lines and Cleared 3 entity(ies)., and chat shows
Cleared 3 discarded piece(s).
Gotchas
This returns immediately. The removal is not visible in ObjectData — including this handle's own properties —
until the host has applied it. Call await handle.refresh() and expect null if you need to confirm it. The
handle keeps its cached values afterwards, so handle.id will go on addressing something that no longer exists.
Applies to: every object kind. Nothing refuses a delete, including a locked entity when the request comes from a script.
The action is delete, not destroy. globalEvents.onObjectAction and ObjectHandle.onAction report the
string "delete", and so does anything else reading the action vocabulary. The method name is the odd one out.
Children are re-parented, not destroyed. An entity with pieces parented to it hands them up to its own ancestor — or leaves them as roots if it had none. Destroying the base of an assembly leaves the assembly's pieces on the table, loose. Walk the pieces yourself if the whole thing should go.
A mod cannot do this at all. delete is not on the mod action allowlist, so a game that needs to remove
entities belongs in a table script. See Action vocabularies.
See also
ObjectHandle.refresh— confirming the entity is gone.world.spawnObject— the other end of an entity's life.- Action vocabularies — why
deleteis the one action a mod is refused. - Parenting — what happens to an assembly when its base is removed.
objecthandle.getSavedData#
getSavedData(key?: string): Promise<string | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Reads one string your scripts previously stored against this entity. The sandbox asks the host for the value
under obj:<id>:<key>, so two entities never share a slot even when they use the same key.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
key |
string |
no | Names one slot on this entity. Anything that is not a string — including omitting it — becomes "", so getSavedData() reads the entity's single unnamed slot. Not trimmed or lowercased: "Note" and "note" are different slots. |
Applicability: every object kind. The scope is the entity's id, and every entity has one.
Returns
Promise<string | null>.
null means no value is stored under that key for this entity. Values are always strings — encode numbers and
structures yourself, and parse defensively on the way back, because a value written by an older version of your
script is exactly as likely as a well-formed one.
The promise rejects if the host refuses the request.
How, why and when to use it
Each token in your game tracks its own hit points, and the number has to be attached to that token rather than
to a table-wide map you have to keep in step as tokens are spawned and destroyed. Per-entity saved data is that
attachment: the key is scoped to the id, so the bookkeeping disappears with the entity. The alternative is
world.getSavedData with the id baked into the key — which works, and leaves you owning the cleanup when the
entity goes away. Use the entity scope when the value belongs to one piece; use the table scope for anything about
the game as a whole.
Example
// content/scripting-api/examples/objecthandle.getSavedData.ts
// Scene script: read the per-entity note stored against each die. Saved data
// here is scoped to one entity, so two dice never share a slot.
const NOTE_KEY = "note";
async function readDieNotes(): Promise<void> {
const dice = await world.getAllObjects({ kind: "die" });
if (dice.length === 0) {
world.log("No dice on the table.");
return;
}
for (const die of dice) {
const note = await die.getSavedData(NOTE_KEY);
world.log(note === null
? `${die.id}: nothing stored under "${NOTE_KEY}".`
: `${die.id}: ${note}`);
}
// Omitting the key reads the entity's one unnamed slot.
const unnamed = await dice[0].getSavedData();
world.log(`${dice[0].id} default slot: ${unnamed === null ? "empty" : unnamed}`);
}
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!notes") {
void readDieNotes();
}
});
void readDieNotes();
Each line reads script-1f2e3d4c5b6a7089: nothing stored under "note". until something writes one, and the
stored text afterwards — including after a reload.
Gotchas
Resolves after a round-trip to the host, so the value is the host's state at the moment it answered — not necessarily still true when your handler continues.
Your key is not the storage key. The host namespaces the request as obj:<objectId>:<key> and then composes
the runtime key as scripts.<scope>.<encoded namespaced key>.v1
(apps/web/src/playcanvas/savedDataKeys.ts, tableScriptSavedDataKey), escaping every character the store's key
pattern does not allow. It is invisible from a script, and it is why the value is not reachable from a mod: mod
saved data is keyed by mod id, in a different key space in the same store.
Edit Mode's store is session-only and is cleared every time you stop or restart the scripts, so the editor cannot show you whether a value truly persists. At a real table it rides the snapshot.
Nothing tidies up after a deleted entity. A slot keyed on an id outlives the entity that id addressed.
See also
ObjectHandle.setSavedData— the write half.world.getSavedData— the same idea scoped to the whole table.ObjectData.metadata— the other per-entity bag, which a script can read but not write.- Known limitations — the full list of documented gaps.
objecthandle.setSavedData#
setSavedData(value: string, key?: string): Promise<void>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Stores one string against this entity for your scripts to read back later. The sandbox sends it to the host
under obj:<id>:<key>, so the value belongs to this entity and no other.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
value |
string |
yes | Coerced with String() before it leaves the sandbox; null and undefined both become "". A plain object becomes "[object Object]" — serialize with JSON.stringify yourself. Nothing throws on a bad value. |
key |
string |
no | Names the slot on this entity. Anything that is not a string — including omitting it — becomes "", the entity's single unnamed slot. Not normalized: "Note" and "note" are different slots. |
Applicability: every object kind.
Returns
Promise<void>.
It resolves with no value once the host has accepted the write, and rejects with an Error when the host
refuses — a write against an id that is not on the table is the usual cause. Await it inside try/catch so the
refusal is visible rather than surfacing as an unhandled rejection.
How, why and when to use it
A token's hit points change every time it is attacked, and the number has to travel with that token — through a
save, a reload, and a host migration. Writing it against the entity's own id is what makes that true without you
maintaining a side table. The alternative is ObjectData.metadata, which does replicate and does persist and is
right there on the handle — but a script cannot write it, so it is only usable for values fixed at spawn time. Use
per-entity saved data for anything about one piece that changes during play.
Example
// content/scripting-api/examples/objecthandle.setSavedData.ts
// Scene script: stamp each die with the peer that last rolled it. The write is
// awaited so a refusal shows up in the console instead of vanishing.
const LAST_ROLLER_KEY = "last-roller";
async function recordRoller(handle: ObjectHandle, actor: string): Promise<void> {
try {
await handle.setSavedData(actor, LAST_ROLLER_KEY);
world.log(`${handle.id} last rolled by ${actor}.`);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
world.log(`${handle.id} roller not stored: ${detail}`);
}
}
globalEvents.onDiceRolled.add((handle, value, context) => {
world.log(`${handle.id} settled (value ${value === null ? "cocked" : String(value)}).`);
void recordRoller(handle, context.actor);
});
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() !== "!clearrollers") {
return;
}
void world.getAllObjects({ kind: "die" }).then((dice) => {
for (const die of dice) {
void recordRoller(die, "nobody");
}
});
});
The script console prints script-1f2e3d4c5b6a7089 last rolled by a1b2c3d4. after each roll.
Gotchas
Resolves once the host has accepted the write. The value reaches other peers with the next snapshot, not when this resolves.
Your key is not the storage key. The host namespaces the request as obj:<objectId>:<key> and then composes
the runtime key as scripts.<scope>.<encoded namespaced key>.v1
(apps/web/src/playcanvas/savedDataKeys.ts, tableScriptSavedDataKey), escaping every character the store's key
pattern does not allow. Nothing about that is visible from a script, and it is why a mod cannot read what a table
script wrote.
Writing to a destroyed entity fails. The host will not persist data against an id that is not on the table, so a write racing a delete is rejected.
Nothing validates what you stored. The slot holds a string and hands it back unchanged, however old the writer was. Version your format if it is more than a number.
See also
ObjectHandle.getSavedData— the read half.world.setSavedData— the same idea scoped to the whole table.ObjectData.metadata— the read-only per-entity bag.- Known limitations — the full list of documented gaps.
ObjectData#
Surface A — table script · interface · 11 members
Read-only data snapshot of a table object.
ObjectData is the ten-field shape a table script can see of an entity: id, kind, name, position,
rotation, locked, faceUp, stackCount, tags and metadata. Every ObjectHandle extends it, and every
one of the ten is derived by one function in the sandbox (apps/web/src/scripting/sandbox/tableScriptSandbox.html,
stateToData) from the entity's replicated state — which is why the same ten values reach you identically
whether they arrived with an event, with a query or with a refresh.
It is also the resolved value of refresh(), and there it is a detached copy: a
plain record with the ten fields and no methods, no delegates and no link back to the handle. The handle's own
fields keep tracking the table; the copy you were handed does not change again.
How, why and when to use it#
You want an entity's state as it stood at one moment — to compare two positions a second apart, or to record what
a card looked like before you flipped it. const before = await handle.refresh(); gives you ten values that
came from one host answer and stay put. The alternative is reading the handle's fields one at a time, which is
right for a single question ("is it locked?") and wrong the moment an await sits between two of your reads: the
sandbox can rewrite the record in that gap, so before.position and before.faceUp end up describing two
different states. Take the copy when you need several fields to agree with each other; read the handle directly
when you need one field and need it now.
Gotchas#
name is the entity's label, not its human-readable name. stateToData populates it from label — the
slug — and returns null for an empty one. displayName is not published to a table script at all. See
ObjectData.name and
A table script cannot read displayName.
Coordinates are in feet. position is absolute table space, so [0, 1, 0] is one foot above the table
origin; rotation is Euler degrees.
Nine fields is far fewer than the entity has. No displayName, no parentId, no components, no color, scale,
physics, owner seat or container contents. Those are on the replicated
object state and are unreachable from this surface.
readonly is a type-level promise, not a frozen object. TypeScript refuses data.locked = true at compile
time and nothing stops you writing into the metadata record at run time — which changes nothing on the table
and is thrown away by the next refresh, because that replaces the whole record. Copy anything you intend to
change.
See also#
ObjectHandle— the interface that extends these ten fields with methods and delegates.ObjectHandle.refresh— the call that resolves anObjectData.ObjectData.name— which of the three names this is.- Object state — the full replicated entity, including the fields this omits.
- Known limitations — every documented gap, in one list.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
kind |
ObjectKind |
|
name |
string | null |
|
position |
Vec3 |
|
rotation |
Vec3 |
|
scale |
The entity's scale. For an imported model this is a MULTIPLIER on the model's own size, not a size in feet — copy it onto a replacement with spawnObject's scale so the two match. |
Vec3 |
locked |
boolean |
|
faceUp |
boolean | null |
|
stackCount |
How many items this entity represents — the height of a card stack, the size of a token pile, the number of cards left in a deck. 1 for anything that does not stack. |
number |
tags |
readonly string[] |
|
metadata |
Readonly<Record<string, unknown>> |
objectdata.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity's address. This is the only value that resolves an entity — world.getObjectById, the sandbox's own
handle table and every host-side lookup key on it, and nothing anywhere looks an entity up by either of its names.
It is assigned when the entity is created and never changes.
How, why and when to use it
You need to act on the same entity across two events — a token picked up in one handler and dropped in another,
or a deck you found at load and want to shuffle three turns later. Store handle.id, not the handle, when the
gap is long or the value has to survive into saved data: an id is a plain string you can persist, compare and
log, whereas a handle is a live object whose properties will have drifted. Store the handle when you are acting
inside the same handler and want the methods. The one thing never to do is identify an entity by
ObjectData.name — that is a label, it is not unique across kinds, and two entities can share it.
Gotchas
An id from world.spawnObject is real before the entity is. The sandbox generates the id — a script- prefix
and sixteen hex characters — and posts it with the spawn intent, so you hold a correct address before the host has
validated anything. If the host rejects the definition, that id addresses nothing forever.
Comparison is exact. Ids are compared with === after string coercion, with no trimming or case folding.
See also
world.getObjectById— turning an id back into a handle.ObjectData.name— the value that is not an address.- IDs, names and tags — the three names and which one resolves.
objectdata.kind#
readonly kind: ObjectKind;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
What kind of thing this entity is: card, deck, die, token, board, bag, custom, card-holder or
button. The kind is fixed at spawn and decides how the host treats the entity — which actions do something,
what its physics defaults are, and what shape it gets.
How, why and when to use it
Your handler has a handle that came from globalEvents.onObjectDropped, so it could be anything a player can pick
up, and you are about to call shuffle() on it. Reading kind first is what tells you whether that call will do
anything at all: on anything but a deck the host refuses it silently — no error, no event, no sound. The
alternative is to filter at the query — world.getAllObjects({ kind: "deck" }) — and that is the
better tool whenever you are choosing which entities to work on, because it never returns the wrong one in the
first place. Read kind when the entity arrived from somewhere you did not choose: an event payload, an id out of
saved data, a handle a player's action handed you.
Gotchas
Not every action checks the kind, so this is your guard rather than the host's. roll on a card throws the
card; flip on a die turns it over and flips its face flag. Only the container actions guard themselves. See
Action vocabularies for exactly which do and do not.
The type is widened, so a typo compiles. ObjectKind names all nine kinds and then admits any other
string, so kind === "dice" type-checks and is always false. Compare against the literals the runtime
actually uses.
See also
world.getAllObjects— filtering by kind at the query instead.- Object kinds — all nine kinds, their physics defaults and their fields.
- Action vocabularies — which actions do something on which kind.
- Object Types — the handle type each kind resolves to in a script.
- PIECE — where an entity's kind is set in Edit Mode.
objectdata.name#
readonly name: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity's label — its slug and machine key, not its human-readable name. The sandbox reads
label off the entity's state and publishes it here under the name name, so a card's name is its card
identity (AS, 10H), a preset's is its preset slug (die-d6), and an entity a script spawned without one is
named after its kind.
null only when the handle has never carried real entity state — a world.spawnObject result whose label
somehow never landed, or a handle built for an id the sandbox has never seen state for. Every entity on the table
has a label, so a refreshed handle always reports a string.
How, why and when to use it
You are logging what a player just did, and Player drew AS reads better than
Player drew script-1f2e3d4c5b6a7089. name is the readable identifier for that, and for kind: "card" it is
more than cosmetic — the label is which card it is, so a hand-scoring routine reads it to know what was played.
The alternative is id, and that is what you must use for anything that has to be exact: labels are not unique,
two decks can both be labeled deck-standard, and a player renaming an entity in Edit Mode changes it. Read
name to display or to identify a card; read id to address an entity.
Gotchas
This is the slug, and it is load-bearing for cards. A card's label drives hidden-information redaction —
which peers are allowed to see which card. It is not free text, and nothing a script does can change it after
spawn.
Known gap. A table script cannot see an entity's human-readable name at all. Entities carry both a
labeland an optionaldisplayName— the one shown in the Hierarchy and the Inspector — and the sandbox publishes onlylabel(apps/web/src/scripting/sandbox/tableScriptSandbox.html,stateToData).SpawnObjectOptions.namelikewise writeslabel. Everything about labels works correctly and every entity reports one; the second name is not exposed at all. This is a Surface A boundary, not a platform one — mod scripting reads the entity state its peer received, which carriesdisplayNameexcept on a card that peer may not identify (redaction deletes it, exactly as it rewriteslabel). Use tags or the label itself when a script needs to recognize an entity by something an author typed. See Known limitations.
See also
ObjectData.id— the value that actually addresses an entity.world.spawnObject— the only place a script writes a label.- IDs, names and tags — the three names, in full.
- Object state — the entity state a mod reads,
displayNameincluded, and what redaction removes from it. - Known limitations — the full list of documented gaps.
objectdata.position#
readonly position: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Where the entity is, as [x, y, z] in feet. The coordinates are table-space and absolute — [0, 1, 0] is one
foot above the table origin — and they stay absolute even for an entity parented to another, so a piece welded to
a board still reports where it is on the table rather than an offset from the board.
How, why and when to use it
You want to lay a drawn card next to the deck it came from, or drop a turn marker beside whichever seat is
active, and both need a coordinate to start from. Reading the position of something already in the right area is
the cheapest way to get one. The alternative is to hard-code the layout as constants, which is what you should do
for fixed furniture — a river, a discard slot, a scoring track — because a constant cannot drift when a player
nudges the piece you were measuring from. Read position when the answer depends on where something currently
is; hard-code when the answer is part of the board.
Gotchas
It is a cached value, not a live read. The array is refreshed when the handle is delivered by a world query,
by an event, or by refresh() — and not in between. A piece being dragged across the table has a stale position
here every frame you did not ask about.
Y is up, and it is the entity's origin rather than the surface it rests on. A card lying on the table does not
report y: 0. Copy a resting height from a similar entity rather than guessing one.
The array is a fresh copy each refresh, and writing to it does nothing. handle.position[0] = 5 changes a
number in the frame and no part of the table. Use
setPosition.
See also
ObjectHandle.setPosition— moving the entity.ObjectHandle.refresh— getting a current value.- Moving, rotating and scaling entities — the same coordinates in Edit Mode.
- Parenting — why a child still reports a world position.
objectdata.rotation#
readonly rotation: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How the entity is oriented, as [x, y, z] Euler angles in degrees, reported in world terms. y is the one
that matters most of the time: it is the entity's heading on the table.
How, why and when to use it
A board has been dragged around all evening and you want to square it up, so you need to know which right angle it
is nearest to before you can snap it. Reading rotation[1] and rounding is how you find that. The alternative is
rotate(), the fixed 90° step, which is right when you want a quarter turn from wherever it is and wrong when
you want it to land on a specific angle — from 37° it gives you 127°. Read rotation when you are computing a
target; skip it entirely when a relative turn is what you meant.
Gotchas
It is a cached value, not a live read, refreshed only when the handle is delivered by a query, an event or
refresh(). A tumbling die's rotation here is whatever it was when you last looked.
Never infer face state from it. A flipped card sits near x: 180, but flip() and the transform path are
separate: an entity rotated to [180, 0, 0] with setRotation looks flipped and still reports its old
faceUp. Read faceUp.
Angles are not normalized to any range, and equivalent orientations do not compare equal. Reduce both sides before you test for equality.
The array is a fresh copy and writing to it does nothing. Use
setRotation.
See also
ObjectHandle.setRotation— setting an absolute angle.ObjectHandle.rotate— the fixed quarter turn.ObjectData.faceUp— the flag that actually tracks the face.- Moving, rotating and scaling entities — the same angles in Edit Mode.
objectdata.scale#
readonly scale: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity's scale. For an imported model this is a MULTIPLIER on the model's own size,
not a size in feet — copy it onto a replacement with spawnObject's scale so the
two match.
The entity's scale per axis.
Returns
Vec3 — [x, y, z]. [1, 1, 1] for an entity with no scale of its own.
How, why and when to use it
Read it when a script replaces one entity with another that must match it — promoting a pawn, swapping a token
for an upgraded one. Pass it straight to spawnObject's
scale, together with position and rotation, and the
replacement stands exactly where and as large as the original did.
Gotchas
For an imported model it is a multiplier, not a size. A chess pawn at [3, 3, 3] is three times its modelled
size, not three feet tall. Compare scales between entities of the same model, not against distances.
It is a copy taken when the handle was last updated, like position — capture it before destroying the entity
you are replacing.
See also
SpawnObjectOptions.scale— where it goes.ObjectData.position— the other field a replacement copies.
objectdata.locked#
readonly locked: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Whether the entity is pinned. true means the host has marked it locked and switched its rigidbody to static, so
physics leaves it alone and players cannot move it or act on it — apart from unlocking it again.
How, why and when to use it
Your script locks the boards at load and you do not want to send a redundant intent for the ones that are already
locked, because every intent costs a snapshot broadcast to every peer. Reading locked first is the filter. It is
also the honest way to answer "did the lock actually take?" after a lock() — call refresh() and read this,
rather than assuming. The alternative is to track lock state in your own variable, which is fine until a player
unlocks something from the object menu and your variable is quietly wrong for the rest of the session.
Gotchas
It is a cached value, not a live read. A player can unlock an entity between your read and your next line.
A lock stops players, not your script. The gate that refuses actions on a locked entity
(packages/shared/src/tableObjects.ts, isObjectActionAllowedForTarget) runs on intents arriving from a player
or spectator. A script's intents are raised on the host and never pass through it, so a true here does not mean
your own flip() or setPosition() will be refused.
Locking is not the same as a static rigidbody. An entity authored static is already immovable by physics and still perfectly draggable. The lock is what stops the hand.
See also
ObjectHandle.lock— setting it.ObjectHandle.unlock— clearing it.- Action vocabularies — the locked rule and its one exception.
- RIGIDBODY — the authored body type a lock replaces.
objectdata.faceUp#
readonly faceUp: boolean | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Which way up the entity is showing. true is face up, false is face down, and the value is the inverse of the
entity's stored face-down flag — the same flag flip() toggles and the hidden-information system reads when it
decides which peers may see a card's face.
null means the handle has never carried real entity state: a world.spawnObject result you have not
refreshed, or a handle the sandbox built for an id it has only ever seen mentioned. Every entity in a host
snapshot has a boolean here, so null is a "not read yet" marker rather than a third face state.
How, why and when to use it
You are turning every card face up at the end of a round, and flip() is a toggle — so calling it on all of them
turns the already-face-up ones back down. Reading faceUp first is what makes the pass idempotent. The
alternative is to track face state yourself from globalEvents.onObjectAction, which is real work and goes wrong
the first time a player flips something while your handler is awaiting something else. Read the flag; do not model
it.
Gotchas
It is a cached value, not a live read. After flip() the handle still reports the old value until the host
answers — await handle.refresh() first, or compare against what you know you asked for.
Applies to: card and deck meaningfully; every other kind carries the flag anyway. flip() is not
kind-guarded, so a die, token, board, bag, custom or card-holder can be turned over and will report
faceUp: false despite having no face. Treat a non-card's value as noise.
Face up is not the same as revealed. For a card, visibility to other peers is decided by the face flag
together with seat ownership and reveal state, and a script cannot request the reveal-* actions at all. A
true here does not by itself guarantee every player can see the card.
See also
ObjectHandle.flip— the only method that changes this.ObjectData.rotation— the orientation that does not track the face.- Action vocabularies — why
reveal-*is not in the script vocabulary. - Object kinds — which kinds have a face at all.
objectdata.stackCount#
readonly stackCount: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How many items this entity represents — the height of a card stack, the
size of a token pile, the number of cards left in a deck. 1 for anything
that does not stack.
How many items this entity represents as one thing on the table: the height of a card stack, the size of a token
pile, the number of cards still in a deck. It is 1 for everything that does not stack, and it is never 0 - an
entity that reaches zero is removed, and you hear about that as onDestroyed rather than as a count.
It is the same number the entity's replicated state carries, so it is exact rather than inferred, and it is the cheapest way to ask "how much is left" without reading contents at all.
How, why and when to use it
You want to announce that the draw pile is running low, or to stop a player splitting a stack that is already a
single card. Both are one comparison here. The alternative for a container is
ContainerObject.cards, which tells you which cards
are left as well as how many - read stackCount when the count is all you need, and cards when identity
matters. For a token pile there is no alternative: stackCount is the only published measure of its size.
Gotchas
It is a cached value, like every other field on a handle. After a draw() the handle still reports the old
count until the host answers - await handle.refresh(), or read the count off the handle an event delivers.
A deck's count and its cards.length can disagree for one moment, in the same way and for the same reason:
they are two fields of one cached record, refreshed together, and the record is only as new as the last refresh.
It counts entries, not faces. A stack of five cards is stackCount: 5 and one entity with one id. Splitting
it produces new entities with new ids; it does not change this entity into five.
See also
ContainerObject.cards- what a container holds, not just how much.ObjectHandle.refresh- making the cached count current.ObjectHandle.draw- the action that decrements a container's count.- Object kinds - which kinds stack at all.
objectdata.tags#
readonly tags: readonly string[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The author tags on this entity: lowercase strings matching [a-z0-9_-], up to 32 characters each and up to 100 of
them. Tags are how an author or a script marks a set of entities as belonging together, and they are the only
grouping mechanism a script can both write and read.
How, why and when to use it
Your scoring pass needs the dice that count and not the ones a player brought for fun, so the ones that count get
a scoring-die tag at spawn and the pass asks for that tag. Reading handle.tags is the other half of that: it
tells you whether an entity that arrived from an event — a drop, a roll — is one of yours. The alternative is
kind, which most scripts try first and which is too coarse the moment a table has two sorts of the same kind.
Read tags for membership in a set you defined; read kind for what a thing fundamentally is.
Gotchas
Read-only, and there is no way to change tags after spawn. The array is a fresh copy on every refresh, so
handle.tags.push("x") changes nothing about the table. world.spawnObject's tags option is the only place a
table script can write one.
Platform dt: tags are not in here. The reserved dt: namespace lives on the engine entity, which no script
can reach; this list is the author tag set only. So you never need to filter platform tags out of it, and you can
never match one with world.getAllObjects({ tag }) either.
Matching is exact and stored tags are lowercase. handle.tags.indexOf("Blue") finds nothing on an entity
tagged blue. Share a const between the code that spawns and the code that looks.
The frame's tag filter and the host's schema disagree on length. A tag matching the character class passes the sandbox's filter at any length, and the host's schema then rejects anything over 32 characters — rejecting the whole spawn, not only the offending tag. Keep tags short.
See also
world.getAllObjects— querying by a single tag.world.spawnObject— the only place a script writes tags.- Tags and groups — authoring tags by hand.
- IDs, names and tags — the
dt:namespace and why it is unforgeable.
objectdata.metadata#
readonly metadata: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity's freeform property bag, as the host published it. It is the same record world.spawnObject writes
through its metadata option, and it carries both what an author put there and what the platform keeps there.
Values are unknown, so every read needs a type check before you use it.
Keys you will actually meet: scriptId (the object script attached to this entity), standardPresetId (the
standard-library preset it was built from), grabbableWhileParented (the per-child opt-out from grab
escalation), cards and stackModelVersion (a deck's contents), cardId and sourceDeckId (on a card a draw
produced), and revealTeam (which team, if any, a card is revealed to).
How, why and when to use it
You spawned a token with metadata: { role: "wizard" } and, three events later, a handler that only has a handle
needs to know what it is. metadata is where that answer survives, because it is part of the entity's replicated
state rather than something you have to track alongside it. The alternative is tags, which are cheaper to filter
on and are the right choice for membership — "is this one of the scoring dice?" — but hold no values. Put a
value in metadata at spawn; put membership in a tag.
Gotchas
It is read-only in practice as well as in the type. The record you get is the frame's own copy of the entity
state, so writing to it changes nothing on the table, replicates to nobody, and is silently thrown away by the
next refresh. There is no method that writes metadata after spawn — world.spawnObject is the only opportunity.
Every value is unknown. Narrow before you use one: typeof value === "string", an Array.isArray check,
and a default for the case where an older version of your script never wrote the key.
parentId and components are not here, and not on ObjectData at all. An entity's parent link and its
engine components are top-level fields on the replicated entity state, and neither is published to a table
script. A script therefore cannot see that an entity is part of an assembly — but it can see
grabbableWhileParented, because that one rides metadata.
Grab escalation means a drop event may not be about the piece the player touched. Grabbing a child of an
assembly escalates to its root ancestor unless that child carries grabbableWhileParented, so
globalEvents.onObjectPickedUp and onObjectDropped report the escalated entity. A script that credits a
move to the piece a player visually grabbed is wrong for every parented assembly. That escalation is deliberate —
see Parenting — so read metadata.grabbableWhileParented if you need to know
which pieces are exempt.
See also
world.spawnObject— the one place a script writes metadata.ObjectHandle.setSavedData— per-entity state a script can change after spawn.- Parenting — grab escalation and the
grabbableWhileParentedopt-out. - Object state — every field of the replicated entity state, including the
ones
ObjectDatadoes not publish. - Events — which entity a pick-up or drop actually reports.
refObject#
Surface A — table script · const
The object this script is attached to. Defined ONLY in object scripts (scripts attached via an object's Script section); undefined in scene scripts.
declare const refObject: ObjectHandle;
refObject is the ObjectHandle for the entity a script is attached to. An entity attaches a
script through its metadata.scriptId, and when the host starts that pair it seeds the entity's state into the
sandbox first and then runs the body — so refObject's ten fields hold real values on your very first line, not
placeholders, and its eight delegates are already wired to that entity alone.
Its declared type follows the entity it was written for. A script created from an entity's Script section
records that entity's kind, and the editor declares refObject as the matching handle — DeckObject for a deck,
CardObject for a card — so refObject.cards and refObject.onFlipped are offered where they apply and refused
where they cannot fire. A global script, and any script authored before scripts recorded a kind, keeps the
generic ObjectHandle. See Object Types.
How, why and when to use it#
You want one behavior that every copy of a piece carries with it: a card that logs its own name when it is drawn,
a die that adds to a running total whenever it settles. The alternative is a scene script that subscribes to
globalEvents.onDiceRolled and opens every handler with a check that the entity is one of yours — a filter you
have to keep in step with the table as pieces are spawned and destroyed. refObject.onRolled
needs no filter, because the delegate fires only for that entity. Attach an object script when the rule belongs to
the piece; write a scene script when the rule is about the table, or when it has to see pieces that do not exist
yet.
Gotchas#
It is typed as always present and is undefined in a scene script.
Known gap. The declaration is
declare const refObject: ObjectHandle;— never optional — while the sandbox passesundefinedfor it whenever a script runs without an entity (packages/shared/src/scripting.ts;apps/web/src/scripting/sandbox/tableScriptSandbox.html,runScript). Autocomplete therefore offersrefObject.flip()inside a scene script and the script throws the moment that line runs. In an object script it is populated before the body executes and every method on it works. In a scene script, reach entities throughworld.getObjectByIdorworld.getAllObjectsinstead. See Known limitations.
One script attached to five entities runs five times. The host starts the pair, not the script, so the body
executes once per attached entity with a different refObject each time. Anything you declare at the top level
is per-run state, which is what you usually want — and it means a counter you meant to share across the five has
to live in saved data instead.
It is the same object every other lookup returns. world.getObjectById(refObject.id) resolves the identical
handle, so refreshing one refreshes both. The lookup's declared type is the generic ObjectHandle, though —
an id says nothing about a kind — so the narrowed members are only on refObject and on a kind-filtered
world.getAllObjects.
The declared kind is a hint, not a gate. Attaching a script written for a deck to a card is allowed and it runs; only the typing and the starter body came from the kind. The Script section says so when the two disagree.
See also#
ObjectHandle— everythingrefObjectcan do, since it is one.ObjectData— the ten fields it carries.- Object Types — the per-kind handle it is declared as, and what each adds.
world.getAllObjects— the scene-script way to reach entities.- Scripts — attaching a script to an entity in the editor.
- Known limitations — the scene-script gap in full.
