Dicey Table

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.
  • TypesVec3, 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 ObjectData omits.
  • 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#

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

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

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 with grabbableWhileParented in its metadata — 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

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 child grabbableWhileParented in its metadata.

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

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

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

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

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

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

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

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#

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.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

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.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#

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.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

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#

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#

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.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.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#

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

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#

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

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

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 label and an optional displayName — the one shown in the Hierarchy and the Inspector — and the sandbox publishes only label (apps/web/src/scripting/sandbox/tableScriptSandbox.html, stateToData). SpawnObjectOptions.name likewise writes label. 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 carries displayName except on a card that peer may not identify (redaction deletes it, exactly as it rewrites label). 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.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

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

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

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

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

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

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

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 grabbableWhileParented opt-out.
  • Object state — every field of the replicated entity state, including the ones ObjectData does 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 passes undefined for it whenever a script runs without an entity (packages/shared/src/scripting.ts; apps/web/src/scripting/sandbox/tableScriptSandbox.html, runScript). Autocomplete therefore offers refObject.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 through world.getObjectById or world.getAllObjects instead. 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#