Events
globalEvents is the second of the three globals injected into every table script. It carries 21 table-wide
delegates: eight about an entity, one about a finished dice roll, two about seat zones, two about authored
trigger volumes, and eight about the room. Together with the eight entity-scoped delegates on every
ObjectHandle, they are the 29 subscription points table scripting has.
Every one of them is a ScriptDelegate — add(fn) to subscribe, remove(fn) to stop.
Handlers run on the host, in registration order, and nothing they return is read. There is no veto hook: by the
time a handler runs, the host has applied the change and broadcast it.
Two things to know before you subscribe to anything. The eight entity delegates pass an
EventContext as their last argument, and its actor field is mostly a peer id but not
always — read EventContext.actor first. Two of its three fields are conditional:
containerId on a draw or an absorb, and reason on a destroy. And globalEvents does not
exist in a mod: mod scripting subscribes with api.on(name, fn) against a separate list of 14 hooks, whose
onTurnStart is not this page's onTurnStarted. See
Mod hooks and capabilities.
The 21 delegates#
| Event | Payload | Who raises it | When |
|---|---|---|---|
onObjectCreated |
ObjectHandle, EventContext |
The runtime, from applyIntent or createGameplayObject |
An entity comes into existence — a spawn, or a draw, deal, split or combine. Not for a snapshot rebuild. |
onObjectDestroyed |
string (the id), EventContext (with reason) |
The runtime | An entity leaves the table: a delete, a draw consuming the deck, or a combine absorbing it. |
onObjectPickedUp |
ObjectHandle, EventContext |
The runtime, from applyIntent or a grab |
A hold starts. Once per entity, so a group drag raises several. |
onObjectDropped |
ObjectHandle, EventContext |
The runtime, from applyIntent or a grab release |
A hold ends. The entity has not settled yet. |
onObjectAction |
ObjectHandle, ObservedObjectAction, EventContext |
The runtime, from applyObjectAction |
Any of the 19 engine actions is applied — including six a script cannot request. |
onDiceRolled |
ObjectHandle, number | null, EventContext |
The runtime | A die comes to rest after tumbling. The value is the printed face, or null when it is cocked or has no face table. |
onDiceRollResult |
DiceRollSummary |
The runtime | A whole batch roll has settled and been totalled. Once per roll, after every onDiceRolled in that batch. |
onCardDrawn |
ObjectHandle (the card), EventContext (with containerId) |
The runtime, from drawCardFromDeck |
A card comes off a deck or bag. |
onContainerShuffled |
ObjectHandle, EventContext |
The runtime | A shuffle is applied — menu, script or shake gesture. |
onZoneEnter |
ZoneEvent |
The host's per-frame seat-zone membership pass | An entity comes to be inside a seat zone. Every zone type; once per crossing. |
onZoneLeave |
ZoneEvent |
The same pass | It moved out, it left the table, or the seat was released and the zone went with it. |
onTriggerEnter |
TriggerEvent |
The host's low-rate trigger-volume membership pass | An entity comes to be inside a trigger volume authored on a model. Once per crossing. |
onTriggerLeave |
TriggerEvent |
The same pass | It moved out of the volume, or it left the table. |
onTurnStarted |
{ peerId, seat, team } |
The app's turn system | Turn order starts, or advances to the next player. |
onTurnEnded |
{ peerId } |
The app's turn system | The active player's turn finishes, just before the next starts. |
onPlayerJoined |
PlayerInfo |
The app's presence watcher | A peer appears in the connected-peer list. |
onPlayerLeft |
PlayerInfo |
The app's presence watcher | A peer disappears from it. |
onSeatChanged |
{ peerId, seat } |
The app's assignment watcher | A peer's seat differs from its previous value. |
onTeamChanged |
{ peerId, team } |
The app's assignment watcher | A peer's team differs from its previous value. |
onChatMessage |
{ peerId, displayName, text } |
The app's chat pipeline | A chat line is processed. Muted peers and host commands never arrive. |
onTick |
number (the dt) |
A timer on the host | Roughly every 100 ms, and only while a handler is registered. |
The eight room events at the bottom of that table pass no EventContext — nobody caused them in the way a
person causes a flip, so there is no actor to report. Neither do the two zone events or the two trigger events: a
crossing says where something ended up, not who put it there. onDiceRollResult passes none either, for the
opposite reason: the roller is already named on its payload, so a context repeating it would be one more place
for the two to disagree.
The four crossing events are the same shape of thing, split by whose geometry was crossed. A seat zone is authored on the table and belongs to a seat, so it travels with whoever claims that seat. A trigger volume is authored on a model, so it travels with the entity it was placed on — and, unlike a collider, it never collides and never affects physics. Raising these two events is the entire whole of what a trigger volume does.
The supporting types#
| Type | Kind | What it is for |
|---|---|---|
ScriptDelegate |
interface | The add/remove shape every one of the 28 subscription points has. |
ZoneEvent |
interface | The four strings a zone crossing reports. |
ZoneType |
type | The nine kinds of seat zone, four of which have behaviour. |
TriggerEvent |
interface | What a trigger-volume crossing reports: the authored volume, the entity, and which way. |
ScriptDelegate.add |
method | Register a handler. |
ScriptDelegate.remove |
method | Unregister one, by identity. |
EventContext |
interface | The object the eight entity events pass last. |
EventContext.actor |
property | Who caused the event — and every value it really takes. |
EventContext.containerId |
property | The container a cardDrawn came from, or the stack an "absorbed" destroy went into. |
EventContext.reason |
property | Why an entity was destroyed; absent on every other event. |
Where to start#
Subscribe to onObjectDropped first. It is the moment a player finished doing
something deliberate, which makes it the natural place for a rule, and its handler shows you the whole model in
one line: a handle, an actor, and no way to refuse what already happened.
For a rule that belongs to one piece rather than to the game, put it on that entity's own delegate instead —
ObjectHandle carries the eight entity-scoped twins, and in an object script
refObject is already the handle you want.
See also#
ObjectHandle— the eight entity-scoped delegates, and everything else a handle can do.World— reading and changing the table from inside a handler.- Types —
PlayerInfo,TurnInfo,ObjectActionand the rest of the payload types. - Events and delegates — scope, ordering, multiplicity and cancellation.
- Host authority — why every one of these fires on exactly one peer.
- Execution order — when your
addcalls happen relative to the table loading. - Mod hooks and capabilities — the other surface's 14 hooks, which are not these.
- Sidecars — the
triggerskey that authors the volumesonTriggerEnterreports on. - Known limitations — every documented gap, in one list.
EventContext#
Surface A — table script · interface · 3 members
Who caused an event.
The last argument of every entity-scoped lifecycle event. It has three fields: actor, which names who caused
the event and is on every one of them; containerId, which names a deck or bag and is present on cardDrawn
and on an "absorbed" objectDestroyed; and reason, which says why an entity was destroyed and is present
only on objectDestroyed. Eight of the sixteen globalEvents delegates pass a context — the eight about an entity —
and all eight ObjectHandle delegates pass it too. The room events (onTurnStarted, onTurnEnded,
onPlayerJoined, onPlayerLeft, onSeatChanged, onTeamChanged, onChatMessage, onTick) pass no context at
all.
How, why and when to use it#
Almost every rule you write has to know whether a player did something or your own script did, because a script
that corrects a placement in an onObjectDropped handler must not treat its own correction as another move.
actor is the only thing in the payload that answers that. The alternative — comparing the entity's position or
keeping a flag while you call a mutator — is fragile the moment a second script is running. Read context.actor
first in any handler that mutates the table, and return early on "Script".
Gotchas#
The values actor takes are capitalised and mostly not peer ids. Read
EventContext.actor before you compare against anything.
containerId is optional in the type, not merely nullable. It is declared containerId?: string | null, so a
handler for an event that is neither cardDrawn nor an "absorbed" destroy sees undefined — and null means
"this is one of those two and the container was not named". See
EventContext.containerId.
reason is only ever set on a destroy. Every other delegate leaves it undefined, so a switch over
ObjectDestroyedReason still needs a default. See
EventContext.reason.
The object is rebuilt for every event. The sandbox constructs a fresh context per lifecycle event
(apps/web/src/scripting/sandbox/tableScriptSandbox.html, routeLifecycleEvent), so it carries no identity you
can compare and holding on to one tells you nothing later. Copy the strings out if you need to keep them.
There is no timestamp, no entity id and no previous value on it. The runtime's own event record carries an
at timestamp; the sandbox does not pass it through. Use Date.now() in the handler if you need one.
actor is not a peer id you can always resolve. Four of its values — "Script", "Host", "You",
"System" — are not peer ids at all, so world.getPlayers().find(p => p.peerId === context.actor) returns
undefined for them.
See also#
EventContext.actor— the field, and every value it really takes.EventContext.containerId— the container field, on a draw and on an absorb.EventContext.reason— the destroy-only field.globalEvents— which delegates pass a context and which do not.- Host authority — the actor table, in the concept page that owns it.
- Known limitations — the full list of documented gaps.
Members#
| Signature | Description | Returns |
|---|---|---|
actor |
What triggered the event: - a peer id — the participant who acted; - "Script" — a table script; - "Host" — the host, where no more specific actor is known; - "You" / "System" — the local viewer on a solo table, before a peer id exists. |
string |
containerId |
A container involved in the event. Present on exactly four events and undefined on every other one: |
string | null |
reason |
Why the entity was destroyed. Present only on objectDestroyed; undefined on every other event. |
ObjectDestroyedReason |
eventcontext.actor#
actor: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
What triggered the event:
- a peer id — the participant who acted;
"Script"— a table script;"Host"— the host, where no more specific actor is known;"You"/"System"— the local viewer on a solo table, before a peer id exists.
The spelling is capitalised. Compare with === against those literals and
treat anything else as a peer id.
Who caused the event. The sandbox reads it off the runtime's event payload and falls back to the string "Host"
when the payload carries no usable actor (apps/web/src/scripting/sandbox/tableScriptSandbox.html,
routeLifecycleEvent). In practice the runtime always supplies one, and there are five shapes it can take.
Returns
string. Never null, never empty in practice. Its value is one of:
| Value | What caused the event |
|---|---|
| a peer id | The common case at a real table. A player or spectator sent the intent — or the local viewer did, through the grab tool, a VR hand or the host's own UI, all of which report the viewer's peer id (apps/web/src/playcanvas/TabletopRuntime.ts, currentActorLabel). |
"Script" |
A table script emitted the intent. The app passes this literal when it dispatches an intent on the script host's behalf (apps/web/src/ui/App.tsx, the TableScriptHost dispatchIntent callback). |
"Host" |
The runtime's own default — an internal path that named no actor (apps/web/src/playcanvas/TabletopRuntime.ts, applyIntent(intent, actor = "Host")). |
"You" |
The local viewer where there is no peer id at all: a solo or offline table. currentActorLabel() is viewerPeerId ?? "You", so this appears exactly when the first is null. |
"System" |
The runtime's other offline label, treated as local alongside "You" (isLocalActor). |
The spelling is capitalised, and the declaration says so (packages/shared/src/scripting.ts). Compare with
=== against those four literals and treat anything else as a peer id.
How, why and when to use it
You wrote an onObjectDropped handler that snaps a piece into a lane with setPosition. Without an actor check
that is fine today and a loop tomorrow, the moment a second script starts moving pieces around: read
context.actor, ignore "Script", and your correction can never be mistaken for a player's move. The other
common use is attribution — "Ada moved out of turn" needs a name, and a peer id is what
world.getPlayers resolves. Check the fixed strings first, then fall
through to a roster lookup.
Example
// content/scripting-api/examples/eventcontext.actor.ts
// Scene script: classify who caused an event. The four fixed strings are
// "Script", "Host", "You" and "System" - anything else is a peer id. The
// spelling is capitalised, so comparing against "script" never matches.
const NON_PEER_ACTORS = ["Script", "Host", "You", "System"];
function describeActor(actor: string): string {
if (actor === "Script") {
return "a table script";
}
if (actor === "Host") {
return "the runtime itself";
}
if (NON_PEER_ACTORS.includes(actor)) {
return "the local viewer on a table with no peer id";
}
const player = world.getPlayers().find((entry) => entry.peerId === actor);
return player === undefined ? `an unknown peer (${actor})` : `${player.displayName ?? actor}`;
}
globalEvents.onObjectAction.add((entity, action, context) => {
world.log(`${action} on ${entity.id} by ${describeActor(context.actor)}.`);
});
globalEvents.onObjectDropped.add((entity, context) => {
// Ignore the script's own corrections so a fix-up cannot trigger itself.
if (context.actor === "Script") {
return;
}
world.log(`${describeActor(context.actor)} placed ${entity.name ?? entity.kind}; nudging it flat.`);
entity.setRotation([0, entity.rotation[1], 0]);
});
world.log("Actor classifier is running.");
A card flipped by the person hosting prints flip on obj-3 by Ada. once the roster has resolved their peer id;
the same flip requested by a remote player prints their name. On a solo table with no peer id it prints
flip on obj-3 by the local viewer on a table with no peer id.
Gotchas
Do not use it as a player key. Four of its five values are not peer ids, so a Map keyed on actor mixes
players and machinery. Filter the fixed strings out first, as the example does.
A peer id is not a name. It is the id the signaling layer assigned, and it means nothing to a player. Resolve
it through world.getPlayers before putting it in a broadcast — and
handle the lookup missing, for someone who has already left.
"Host" does not mean "the person hosting did it". It is the runtime's default for a path that named nobody
— an internal correction, a depletion cleanup. A deliberate action by the person hosting arrives with their peer
id.
In Edit Mode the actor is the synthetic editor peer, not a real id, so a script that branches on specific peer ids takes a different path under ▶ Play Scripts than it does at a table.
See also
EventContext— the object this field lives on, and which events carry it.EventContext.containerId— the container field, oncardDrawnand on an"absorbed"destroy.EventContext.reason— the destroy-only field.world.getPlayers— turning a peer id into a name.- Host authority — the actor table and why a script is one actor among several.
- Known limitations — the full list of documented gaps.
eventcontext.containerId#
containerId?: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A container involved in the event. Present on exactly four events and
undefined on every other one:
cardDrawn— the deck or bag the card came out of;objectDestroyedwithreason === "absorbed"— the surviving stack the entity was folded into;objectEnteredContainer— the container that took the piece;objectLeftContainer— the container the piece came out of.
null means the event is one of those and the runtime named no
container. On the two container events it is always a real id.
The container an event is about. It is one of the two fields on
EventContext that are not on every event: the sandbox adds it on
exactly four, and leaves it undefined on every other delegate
(apps/web/src/scripting/sandbox/tableScriptSandbox.html, routeLifecycleEvent).
| Event | What the id names |
|---|---|
cardDrawn |
The deck or bag the card came out of. |
objectDestroyed with reason "absorbed" |
The surviving stack the entity was folded into. |
objectEnteredContainer |
The container that took the piece. |
objectLeftContainer |
The container the piece came out of. |
All four are the same question — which container? — asked from one end or the other. They share one field
deliberately rather than growing a second, nearly identical destinationContainerId.
Returns
string | null | undefined, and the three answers mean three different things:
| Value | Meaning |
|---|---|
a string |
The container's id, per the table above. Pass it to world.getObjectById for a handle. |
null |
This is one of the four events, and the runtime named no container. On the two container events it never happens — the id is always real there. |
undefined |
This is none of the four. The field is never populated for any other event. |
How, why and when to use it
Draw handling almost always needs both halves — which card, and which pile. globalEvents.onCardDrawn hands you
the card; this field is the pile, and it is what lets one handler serve several decks without a separate
subscription each. The alternative is card.metadata.sourceDeckId, which the runtime also writes and which still
works — but it is metadata rather than a declared field, it is absent on a card produced by a split, and it reads
as a convention rather than as part of the event. Prefer context.containerId; keep the metadata read only for a
card you were handed outside a draw event.
The destroy side answers the mirror question. When a player drags cards together, each absorbed entity raises
onObjectDestroyed with reason: "absorbed" and this field set to the surviving stack — so a script can follow
a card into the pile that took it instead of watching ids vanish.
The two container events use it for the same job in the piece lane:
onObjectEnteredContainer and
onObjectLeftContainer hand you the piece and
name the container here, which is what lets one scene-script handler cover every bowl and bag on the table.
It is also the field the sandbox routes by. The entity-scoped
ObjectHandle.onCardDrawn,
BagObject.onObjectEntered and
BagObject.onObjectLeft are all delivered to the
handle this id names, so a container's own delegates and this field always agree.
Example
// content/scripting-api/examples/eventcontext.containerId.ts
// Scene script: count draws per container. context.containerId is carried by
// exactly four events - cardDrawn, an "absorbed" objectDestroyed, and the two
// container events - and is undefined on every other one.
const drawsByContainer = new Map<string, number>();
globalEvents.onCardDrawn.add((card, context) => {
const containerId = context.containerId;
if (!containerId) {
// null means the runtime could not name the container. Rare, but the field
// is declared `string | null` and a handler that assumes otherwise breaks.
world.log(`${card.id} was drawn from an unnamed container.`);
return;
}
const drawn = (drawsByContainer.get(containerId) ?? 0) + 1;
drawsByContainer.set(containerId, drawn);
world.log(`${context.actor} drew ${card.name ?? card.id} from ${containerId} (${drawn} so far).`);
});
globalEvents.onObjectDestroyed.add((entityId, context) => {
// An entity folded into a stack by a combine. Same field, opposite direction
// - this is where it WENT.
if (context.reason === "absorbed") {
world.log(`${entityId} was absorbed into ${context.containerId ?? "an unnamed stack"}.`);
}
});
globalEvents.onObjectEnteredContainer.add((piece, context) => {
// The piece lane. On the two container events the id is always real, so the
// null branch above has no equivalent here.
world.log(`${piece.id} went into ${context.containerId}.`);
});
globalEvents.onObjectLeftContainer.add((piece, context) => {
world.log(`${piece.id} came out of ${context.containerId}.`);
});
globalEvents.onObjectDropped.add((entity, context) => {
// Every other event leaves containerId undefined - this line never logs.
if (context.containerId !== undefined) {
world.log(`unexpected containerId on a drop of ${entity.id}: ${String(context.containerId)}`);
}
});
world.log("Draw counter is running.");
Each draw prints a1b2c3d4 drew ace-of-spades from obj-2 (1 so far). Dropping a card onto that deck prints
obj-5 was absorbed into obj-2. Dropping a stone into a bowl prints obj-9 went into obj-7. The
unexpected containerId line never appears.
Gotchas
The field is optional, so strict makes you handle three cases, not two. containerId is declared
containerId?: string | null, which means a bare if (context.containerId === null) misses the undefined case
and a bare truthiness check conflates them. The example's if (!containerId) is deliberate: for a draw handler,
"no usable id" is one branch.
The two container events never give you null. objectEnteredContainer and objectLeftContainer always
name a real container — the event exists because a container acted. The declared type is still
string | null | undefined because it is one field shared with events that can answer otherwise, so the
compiler will make you narrow anyway.
It is an id, not a handle. The container may already be gone by the time you use it — drawing the last card
removes the emptied deck, and drawing down to one card converts the remainder into a plain card. Both happen
after cardDrawn, so world.getObjectById(containerId) can resolve null on the very draw that reported it.
It names the container the runtime drew from, which is not always the entity a player clicked. A draw requested against a deck inside a card holder reports the deck.
On an absorb it can name a stack that did not exist a moment earlier. A card-onto-card merge destroys both
cards before creating the deck that replaces them; the id is generated up front so the event can carry it.
Because lifecycle events cross a postMessage boundary, the deck resolves by the time your handler runs.
Check reason before you read it on a destroy. "deleted", "depleted" and "converted" leave it
undefined — only "absorbed" populates it.
On objectEnteredContainer for a bag, the id outlives the piece. The container is still there; the piece
that went in is not. world.getObjectById on the piece's id resolves null, while the container's id is
good.
See also
EventContext— the object this field lives on.EventContext.reason— which destroy populates it.globalEvents.onCardDrawn— one of the four events that carry it.globalEvents.onObjectEnteredContainer— andonObjectLeftContainer, the piece lane's pair.ObjectHandle.onCardDrawn— the deck-scoped delegate this field routes.world.getObjectById— turning the id into a handle.ObjectData.metadata— wheresourceDeckIdstill lives.
eventcontext.reason#
reason?: ObjectDestroyedReason;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Why the entity was destroyed. Present only on objectDestroyed;
undefined on every other event.
Why the entity was destroyed. It is the second of the two fields on
EventContext that are not on every event: the sandbox adds it only
when it routes an objectDestroyed (apps/web/src/scripting/sandbox/tableScriptSandbox.html,
routeLifecycleEvent), so on every other delegate it is undefined.
Returns
ObjectDestroyedReason | undefined — "deleted",
"depleted", "converted" or "absorbed" inside a destroy handler, and undefined everywhere else. The four
values and the runtime path behind each are set out on
ObjectDestroyedReason.
When it is "absorbed", EventContext.containerId is
also populated and names the surviving stack. That is the same field a cardDrawn uses to name the deck a card
came out of — deliberately one field rather than two nearly-identical ones, so "which container is this event
about?" has a single answer.
How, why and when to use it
A destroy handler that treats every disappearance the same is wrong about half of them. Three of the four
reasons are the runtime tidying up — an emptied deck, a one-card deck becoming a card, a merge — and only
"deleted" is a player or a script asking for the entity to go. Read reason first, then decide whether to
announce a loss, drop an id from an index, or follow the contents into a pile.
Example
// content/scripting-api/examples/eventcontext.reason.ts
// Scene script: follow a card into the pile that took it. `reason` is the only
// way to tell an absorb from a delete, and on an absorb `containerId` names the
// surviving stack - the same field a draw uses to name the deck it came from.
const pileOf = new Map<string, string>();
globalEvents.onObjectDestroyed.add((entityId, context) => {
if (context.reason !== "absorbed") {
// Deleted, depleted or converted: the entity is genuinely gone from play.
pileOf.delete(entityId);
return;
}
const containerId = context.containerId;
if (!containerId) {
// Declared `string | null | undefined`. `null` here means the runtime named
// no container; a handler that assumes an id breaks on it.
world.log(`${entityId} was absorbed into an unnamed stack.`);
return;
}
pileOf.set(entityId, containerId);
world.log(`${entityId} is now part of ${containerId} (${context.actor} merged it).`);
});
globalEvents.onCardDrawn.add((card, context) => {
// Card identity, not entity id, is what survives a merge: the entity drawn
// back out is a NEW one. Reconnect on the label the deck kept.
world.log(`${card.name ?? card.id} came back out of ${context.containerId ?? "an unnamed container"}.`);
});
world.log(`Tracking absorbs (${pileOf.size} so far).`);
Dropping a card onto a deck prints obj-4 is now part of obj-2 (a1b2c3d4 merged it). Deleting a token prints
nothing — the handler returns early.
Gotchas
It is optional in the type, not merely nullable. reason?: ObjectDestroyedReason means a handler for any
other event sees undefined, so context.reason === "deleted" is safe but a switch still needs a default.
A missing reason reads as "deleted". The sandbox defaults it rather than passing undefined into a destroy
handler, so a hand-built event or an older host degrades to the ordinary cause instead of to a value every
handler has to guard.
"absorbed" names a container that may not have existed when the event was raised. A card-onto-card merge
destroys both cards before it creates the deck that replaces them, and the id in containerId is generated up
front for exactly this reason. Lifecycle events cross a postMessage boundary, so by the time your handler runs
the merge has finished and world.getObjectById(containerId) resolves — but do not assume the same inside a
synchronous chain of your own.
Do not use it to keep entity ids alive across a merge. An absorbed card comes back out of the pile as a
new entity with a new id. Track card identity (cardId / label) instead — see
ObjectDestroyedReason.
See also
ObjectDestroyedReason— the four values, and the runtime path behind each.EventContext.containerId— the field"absorbed"populates.globalEvents.onObjectDestroyed— the delegate that carries it.ObjectHandle.onDestroyed— the entity-scoped twin, whose only argument is this context.- Events and delegates — where each destroy falls in an action's ordering.
ZoneType#
Surface A — table script · type
What kind of seat zone this is. The first four have behaviour; the rest are accepted by the scene schema and are inert for now. Enter/leave events fire for ALL of them.
declare type ZoneType =
| "hand" | "area" | "hidden" | "scripting"
| "reveal" | "layout" | "randomize" | "fog-of-war" | "drop";
What kind of seat zone a crossing happened in. It is the value of
ZoneEvent.zoneType, and it is the only thing in a zone event
that tells you what the zone is for — the geometry itself is authored in the scene and never reaches a script.
How, why and when to use it#
Enter and leave fire for every type, so a handler that does not branch on zoneType will run for a discard
pile and a private hand alike. Branch on it first and do the work second; that one check is what stops a
"count the cards in play" rule from counting the cards somebody is holding.
The four types with behaviour are hand (the seat's private hand — dropping an entity here gives it an
ownerSeat), area (a play area, which gates who may interact but confers no ownership), hidden (contents
concealed from anyone not entitled to the seat), and scripting (a trigger volume with no behaviour of its
own, which exists precisely so a script can attach meaning to a region).
Gotchas#
Five of the nine types do nothing yet. reveal, layout, randomize, fog-of-war and drop parse and
render, and they raise enter/leave like any other zone, but no engine rule reads them. A scene authored against
one of them behaves as an inert region until the feature lands — which is deliberate, so a scene written on a
newer client still loads on an older one.
scripting is not the only type you get events for. Tabletop Simulator once restricted its zone callbacks
to scripting zones and then retired that restriction; DiceyTable starts where TTS ended up. Keep the type for
"a region that means something to my rules and nothing to the engine", not because it is the only one that
fires.
An untyped legacy box may raise nothing at all. A seat authored before zone types existed has its first box
promoted to hand and the rest to area; but a seat that carries a mix of typed and untyped boxes leaves the
untyped ones inert, and an inert box is not an event source.
See also#
ZoneEvent.zoneType— where you read it.globalEvents.onZoneEnter— the event that carries it.- Zones and seats — how zones and their types are authored.
ZoneEvent#
Surface A — table script · interface · 4 members
One entity crossing one seat zone, on onZoneEnter / onZoneLeave.
Four strings and nothing else — no handle, no engine id. Pass objectId to
world.getObjectById when you need the entity itself.
The single argument handed to globalEvents.onZoneEnter
and globalEvents.onZoneLeave. It names the zone, its
seat, its type and the entity that crossed the boundary — four strings, and deliberately nothing else.
How, why and when to use it#
It is the smallest payload that answers "who went where". There is no handle on it because a script's world is
the replicated snapshot, not the scene graph: pass
objectId to
world.getObjectById when you actually need the entity, and
skip that round trip entirely for the many rules — counters, turn gates, "is this seat's area empty yet" — that
only need the ids.
Gotchas#
The zone is identified by a pair, not by zoneId alone. An authored zone id is unique only within its
seat, so two seats routinely both own a box called seat-zone-red-0's equivalent for their own colour. Key any
map you build on seat and zoneId together.
There is no geometry here, and there will not be. Seat zones live in the scene document, not in the replicated snapshot, so a script cannot read a zone's position or size. Ask the question the other way round — subscribe to the crossings, or ask a zone what it holds — rather than trying to reconstruct the boxes.
See also#
ZoneType— the nine kinds of zone, and which four do something.globalEvents.onZoneEnter— when it fires.globalEvents.onZoneLeave— the three ways it ends.world.getObjectById— turningobjectIdinto a handle.
Members#
| Signature | Description | Returns |
|---|---|---|
zoneId |
The authored zone id. Unique only WITHIN its seat, so key on seat + zoneId together. |
string |
zoneType |
ZoneType |
|
seat |
The seat that owns the zone, e.g. "red" — or null for a TABLE zone that belongs to nobody (a shared deck slot or discard pile). |
string | null |
objectId |
The entity that crossed the boundary. | string |
zoneevent.zoneId#
readonly zoneId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The authored zone id. Unique only WITHIN its seat, so key on seat + zoneId
together.
The authored id of the zone box that was crossed — the identity the scene gave it, carried through unchanged so a zone stays the same zone across frames, seat claims and scene reloads.
Returns
string. Scenes authored in Edit Mode name their boxes seat-zone-<seat>-<index>, but that is a convention of
the editor, not a rule: an imported or hand-written scene may use anything.
How, why and when to use it
Use it whenever a seat owns more than one zone and the rule differs between them — a hand and a discard pile
both raise crossings for the same seat, and zoneId is what tells them apart when
zoneType does not (two area zones, say). For anything
per-seat rather than per-zone, seat alone is the simpler key.
Gotchas
It is unique only WITHIN its seat. Two seats can and usually do carry boxes with the same id. A map keyed
on zoneId alone silently merges every seat's zone into one bucket — key on `${event.seat}/${event.zoneId}`
or an equivalent pair.
Do not hard-code one. The ids come from whichever scene the table loaded. A rule that names a literal id
works on the scene it was written against and silently does nothing everywhere else; branch on zoneType
when you can, and treat ids as opaque when you cannot.
See also
seat— the other half of the key.zoneType— the usually better thing to branch on.- Zones and seats — where the ids come from.
zoneevent.zoneType#
readonly zoneType: ZoneType;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
What kind of zone was crossed. This is the field almost every zone handler branches on first, because enter and leave fire for all zone types and most rules only mean something for one of them.
Returns
A ZoneType: hand, area, hidden or scripting for the four types
with behaviour, or one of the five deferred ones (reveal, layout, randomize, fog-of-war, drop) that
parse and raise events but that no engine rule reads yet.
How, why and when to use it
Reach for it to separate "in play" from "in somebody's hand" without knowing anything about the scene's
layout. A rule written against zoneType keeps working when an author renames a box, adds a second discard
pile, or applies the seat template to three more seats — none of which a rule written against
zoneId survives.
Gotchas
It is the effective type, not necessarily the authored one. A seat whose boxes carry no explicit type at
all has its first box treated as hand and the rest as area, so a scene authored before zone types existed
still reports sensible values here.
A zone with no effective type raises nothing. You will never see this field empty, because an inert box — an untyped leftover in a seat that does carry typed boxes — is not tracked and therefore never fires.
See also
ZoneType— what each value means.zoneId— when the type is not specific enough.- Zones and seats — choosing a type when authoring.
zoneevent.seat#
readonly seat: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The seat that owns the zone, e.g. "red" — or null for a TABLE zone that belongs
to nobody (a shared deck slot or discard pile).
The seat that owns the zone — whose area, hand or pile was crossed. A seat identifier such as red or blue,
the same vocabulary ObjectData's owner fields and the seat
events use.
Returns
string. Always a real seat: a zone belongs to exactly one seat by construction, so unlike most seat-shaped
fields on this surface this one is never null.
How, why and when to use it
It answers "whose?" — which is the question most zone rules are really asking. Scoring, turn gating and
per-player tallies all key on it, and it composes with
zoneId to identify one specific box when a seat owns several.
Gotchas
The seat that owns the zone is not necessarily the player who moved the entity. A zone event says where
something ended up, not who put it there. If you need the actor, watch
globalEvents.onObjectDropped, which carries an
EventContext, and correlate.
At a live table only CLAIMED seats have zones. An empty chair's boxes are not rendered and not tracked, so you will never see crossings for a seat nobody is sitting in — and every occupant of a seat's zones is owed a leave the moment that seat is released.
See also
zoneId— the other half of a zone's identity.globalEvents.onSeatChanged— who is sitting where.- Zones and seats — seats, zones and the seat template.
zoneevent.objectId#
readonly objectId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity that crossed the boundary.
The entity that crossed the boundary, as an id. Not a handle: a script's world is the replicated snapshot, so a zone event names the entity and leaves resolving it to you.
Returns
string. Pass it to world.getObjectById for a handle, which
resolves null when the entity is no longer on the table.
How, why and when to use it
Half of what you write against zone crossings never needs the entity at all — counting occupants, gating a turn, noticing that a seat's area emptied. For those, the id is the whole answer and skipping the lookup keeps the handler synchronous. Resolve it when the rule depends on what moved: the kind, the face it is showing, its tags.
Gotchas
On a leave, the entity may already be gone. One of the three things a leave means is "this entity left the
table" — it was drawn into a container, combined into a stack or deleted. getObjectById returns null, and
that is not an error; it is the signal. Ask the zone what remains instead of assuming the entity is around to
be inspected.
Every entity is reported, furniture included. The membership pass samples everything on the table, so a
locked board or a card holder standing inside a zone raises crossings exactly like a card does. Filter on
kind, or give the zone a tagFilter when authoring, if your rule is only about game pieces.
See also
world.getObjectById— turning it into a handle.globalEvents.onZoneLeave— why the lookup can fail.ObjectData.kind— separating furniture from pieces.
TriggerEvent#
Surface A — table script · interface · 6 members
One entity crossing one authored TRIGGER VOLUME on a model, on onTriggerEnter /
onTriggerLeave.
Plain data and nothing else — no handle, no engine id. Pass objectId to
world.getObjectById when you need the entity itself.
The single argument handed to
globalEvents.onTriggerEnter and
globalEvents.onTriggerLeave. It names the authored
trigger volume that was crossed, the entity carrying it, the entity that crossed it and which way — five
strings and a two-value union, and deliberately nothing else.
How, why and when to use it#
It is the smallest payload that answers "what went where". There is no handle on it because a script's world is
the replicated snapshot, not the scene graph: pass
objectId to
world.getObjectById when you actually need the entity, and
skip that round trip for the many rules — counters, gates, "is this slot filled yet" — that only need the ids.
It is the model-authored twin of ZoneEvent, and the two are
intentionally the same shape of thing: a crossing, reported once, by the host, as plain data.
Gotchas#
Two of the five fields are entity ids, and they are different entities.
ownerObjectId CARRIES the volume;
objectId CROSSED it. Resolving the wrong one gets you a handle
that compiles and describes the wrong piece.
triggerId alone does not identify a volume. It is unique only within its model asset, so two copies of the
same board both report "goal-slot". The unique key for one volume is ownerObjectId + triggerId — key every
per-volume map on the pair.
There is no geometry here, and there will not be. A trigger volume is a local physics artefact rebuilt from the model's authored configuration; it never enters the replicated snapshot, so a script cannot read its position, rotation or size. Subscribe to the crossings instead of trying to reconstruct the shapes.
Every field is authored data, not engine data. triggerId is the id an author typed in the Model editor,
not an engine identifier, and nothing in this payload or behind it reaches a pc.Entity.
See also#
globalEvents.onTriggerEnter— when it arrives, with the worked example.globalEvents.onTriggerLeave— the two ways it ends.ZoneEvent— the same shape, for seat-owned geometry.world.getObjectById— turningobjectIdinto a handle.
Members#
| Signature | Description | Returns |
|---|---|---|
triggerId |
The authored trigger volume's id. Unique only WITHIN its model asset, so it does NOT identify a volume on its own — two copies of the same model each carry a volume with this same id. Key on ownerObjectId + triggerId together, or on triggerTag for the coarser "any volume of this sort" match. |
string |
triggerName |
The authored trigger volume's name. | string |
triggerTag |
The author's tag on the volume, when it set one. | string |
ownerObjectId |
The entity CARRYING the volume. With triggerId, the unique key for one volume. |
string |
objectId |
The entity that CROSSED the boundary — a different entity from ownerObjectId. |
string |
phase |
"enter" | "leave" |
triggerevent.triggerId#
readonly triggerId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The authored trigger volume's id. Unique only WITHIN its model asset, so it does NOT
identify a volume on its own — two copies of the same model each carry a volume with
this same id. Key on ownerObjectId + triggerId together, or on triggerTag
for the coarser "any volume of this sort" match.
The authored id of the trigger volume that was crossed — the identity the model's author gave it, carried through unchanged so a volume stays the same volume across samples, snapshot rebuilds and reloads.
Returns
string, 1–64 characters. It comes from the model's authored configuration (triggers[].id in the model's
.meta.json sidecar, or a platform model's preset override), so its spelling is the author's choice and nothing
about it is generated.
How, why and when to use it
Use it when one model carries several volumes and the rule differs between them — a board with a slot per seat,
a track with a segment per space. Prefer triggerTag for
matching when the author provided one: a tag is the identifier they added for a rule, so it survives the volume
being renamed or re-created.
For keying — a map, a count, a highlight — pair it with
ownerObjectId: the two together are the unique identity of
one volume on one entity.
Gotchas
It is unique only WITHIN its model asset, so it is not a key on its own. Two copies of the same model both
report the same triggerId for the same volume. Key on
ownerObjectId and triggerId together. A map keyed on
triggerId alone appears to work with one copy of the model on the table and silently merges every copy into one
bucket the moment an author adds a second — a bug that only shows up in playtesting.
Do not hard-code one against somebody else's model. The ids come from whichever model an author dropped on the table. A rule naming a literal id works on the model it was written against and silently does nothing everywhere else.
See also
ownerObjectId— the other half of the key.triggerTag— the identifier meant for matching.triggerName— the human label, and why it is not a key.- Sidecars — where a volume's
idis authored.
triggerevent.triggerName#
readonly triggerName: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The authored trigger volume's name.
The authored volume's name — the human-readable label an author typed beside it, carried through so a log line or an on-table message can say "reached Red Goal" instead of "reached trigger-3".
Returns
string, 1–64 characters, and always present: unlike triggerTag a name is required when authoring a volume.
How, why and when to use it
Print it. That is what it is for. It is the one field on this payload written for a person rather than for code,
so it belongs in world.log, a
world.broadcast line, or a label you write onto the table.
Gotchas
Do not key a rule on it. A name is editable at any time in the Model editor, and nothing warns an author
that a script matched on it. Match on triggerTag — which
exists precisely so the matchable identifier and the printable one are different fields — or on
triggerId if the author gave no tag.
It is not unique. Nothing stops two volumes on one model, or two different models, carrying the same name.
See also
triggerTag— what to match on instead.triggerId— the authored identity.world.log— where this usually ends up.
triggerevent.triggerTag#
readonly triggerTag?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The author's tag on the volume, when it set one.
The author's tag on the volume, when they set one. This is the field to match on — a tag is the identifier a model author adds specifically so a rule can recognise the volume, which makes it the only one of the three that is safe to compare against a literal.
Returns
string | undefined. Present only when the author tagged the volume. It follows the platform's author-tag rules
— 1–32 characters matching ^[a-z0-9_-]+$ — and the reserved dt: namespace is rejected at authoring time,
so a triggerTag can never be a platform tag.
How, why and when to use it
Branch on it first, and treat a volume with no tag as "not for me". A rule written as
if (event.triggerTag !== "goal") return; keeps working when the author renames the volume, re-creates it, or
copies the model — none of which a rule written against
triggerName survives.
If you are publishing a mod that expects tagged volumes, say which tags in the mod's description. The tag is a contract between a model author and a script author, and nothing in the platform enforces it for you.
Gotchas
Optional means optional. Compare against your literal rather than testing for presence, so an untagged
volume falls through instead of matching by accident. event.triggerTag !== "goal" is right;
event.triggerTag && … invites a rule that fires on every tagged volume on the table.
It is a class, not an identity. The tag lives on the model asset, so every copy of the model reports it — which
is exactly what makes it the right thing to match on and the wrong thing to key on. When you need to tell two
volumes apart, key on
ownerObjectId + triggerId.
A trigger tag is not an entity tag. It is authored on the volume, not on the entity, and it never appears in
ObjectData.tags or in any tag filter.
See also
triggerName— the printable label, and why it is not this.triggerId— the fallback when there is no tag.- Tags and groups — the author-tag rules and the reserved
dt:namespace.
triggerevent.ownerObjectId#
readonly ownerObjectId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity CARRYING the volume. With triggerId, the unique key for one volume.
The entity carrying the trigger volume — the board with the slot, the cup, the track. Not the entity that
crossed it; that is objectId, and confusing the two is the
single easiest mistake to make with this payload.
Returns
string. An entity id like any other: pass it to
world.getObjectById for a handle.
How, why and when to use it
It is half of the only unique key a volume has. An authored triggerId is unique only within its model
asset, so two copies of the same board both report "goal-slot". `${event.ownerObjectId}/${event.triggerId}`
names exactly one volume on exactly one entity, and is what any per-volume map, count or highlight should be keyed
on.
It is also how a rule tells which board scored: resolve it when the message or the score needs to name the owner, and skip the lookup when the id alone is enough.
Gotchas
Two entities, two ids, one event. ownerObjectId and objectId are always different entities. A handler that
resolves the wrong one gets a handle that compiles, runs, and reports the board's kind where it meant the card's.
Do not key on triggerId alone. With one copy of a model on the table it appears to work; the day an author
adds a second board every count silently merges into one bucket. This is the failure that only shows up in
playtesting.
The owner may be gone on a leave. An entity leaving the table takes its volumes with it, so
getObjectById(event.ownerObjectId) can resolve null in a leave handler. Drop the key from your map rather than
assuming the owner is around to inspect.
See also
objectId— the other id, and the other entity.triggerId— the other half of the key.world.getObjectById— resolving it.
triggerevent.objectId#
readonly objectId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity that CROSSED the boundary — a different entity from ownerObjectId.
The entity that crossed the boundary, as an id. Not the entity that carries the volume — that is
ownerObjectId, and mixing the two up is the single most
common misreading of this payload. Not a handle either: a script's world is the replicated snapshot, so the event
names the entity and leaves resolving it to you.
Returns
string. Pass it to world.getObjectById for a handle, which
resolves null when the entity is no longer on the table.
How, why and when to use it
Half of what you write against trigger crossings never needs the entity at all — counting occupants, gating a turn, noticing that a slot filled. For those the id is the whole answer and skipping the lookup keeps the handler synchronous. Resolve it when the rule depends on what crossed: the kind, the face it is showing, its tags.
Gotchas
On a leave, the entity may already be gone. One of the two things a leave means is "this entity left the
table" — drawn into a container, combined into a stack, or deleted. getObjectById returning null is the
signal, not an error.
It is reported for hidden and face-down entities too. A card in a hand or in a hidden seat zone crosses a
trigger volume like anything else and its id is handed to you, exactly as onZoneEnter already does for hidden
zones. On the host, redaction is a wire-only transform, so a table script has always read the unredacted table
and this is not a new disclosure.
Every entity is reported, furniture included. The sampling pass tests everything on the table, so a locked
board standing inside a volume crosses like a card does. Filter on kind yourself if the rule is only about
game pieces.
A volume never reports its own owner as having crossed it. objectId and
ownerObjectId are always two different entities, so you
do not need to guard against the board triggering its own slot.
See also
world.getObjectById— turning it into a handle.ownerObjectId— the other id on this payload, and the other entity.globalEvents.onTriggerLeave— why the lookup can fail.
triggerevent.phase#
readonly phase: "enter" | "leave";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Which way the entity crossed: "enter" on
onTriggerEnter and "leave" on
onTriggerLeave. It carries no information the delegate
you subscribed to did not already give you — it is here so that one function can serve both delegates.
Returns
"enter" | "leave". There is no third value and no "still inside" phase; the host reports transitions only.
How, why and when to use it
Write the handler once, register it on both delegates, and branch on phase for the parts that differ. That is
usually the clearer shape for a counter or a per-volume set, because the increment and the decrement stay next to
each other where an inconsistency between them is visible.
Use it as your log verb too — but not with naive string concatenation; "enter" and "leave" are not both
regular in English.
Gotchas
Registering one handler on both delegates does not deduplicate anything. The two delegates are independent lists; a handler on both is called once per crossing per direction, which is exactly what you want, and a handler added twice to the same delegate is called twice, which is not.
Do not read it as physics state. A crossing is sampled at a fixed low rate against the crossing entity's
origin point, so "enter" means "the pass saw it inside and had not before" — not "a collision began". A trigger
volume never collides at all.
See also
globalEvents.onTriggerEnter— where"enter"comes from.globalEvents.onTriggerLeave— and"leave".ScriptDelegate—add/remove, and why registering twice runs twice.
ScriptDelegate#
Surface A — table script · interface · 2 members
A typed multicast event you can subscribe to.
The shape every subscription point in table scripting has: two methods, add and remove, and a type parameter
naming the tuple of arguments a handler is called with. All 28 delegates — the 20 on globalEvents and the 8 on
every ObjectHandle — are instances of it. You never construct one; you only subscribe to one that already
exists.
How, why and when to use it#
You will meet ScriptDelegate in autocomplete before you meet it in your own code, and its type parameter is the
fastest way to find out what a handler is given: ScriptDelegate<[ObjectHandle, ObjectAction, EventContext]> says
your handler takes three arguments, in that order, and the editor will infer their types for you. Write handlers
as named functions rather than inline arrows whenever there is any chance you will want them gone later — that is
the whole difference between a subscription you can cancel and one you cannot.
Gotchas#
Multicast, in registration order. The delegate keeps a list. Every handler on it is called for every event, in the order they were added, and the sandbox iterates a copy of that list — so adding or removing a handler from inside a handler is safe and takes effect on the next event, not this one.
A throwing handler does not stop the others. Each call is wrapped individually; a failure is reported as a
handler-phase diagnostic attributed to the script that registered it, and the fan-out continues.
Handlers are never awaited. The declared return type is void and the sandbox discards whatever comes back.
An async handler runs its synchronous part during the fan-out and continues afterwards, so two async handlers
on one delegate interleave, and a rejected promise inside one never becomes a diagnostic. Wrap the body in your
own try/catch if you want to see the failure.
Ownership is recorded at add time. The sandbox remembers which script registered each handler, so a
handler that throws is blamed on the script that added it rather than on whatever was running when the event
arrived.
See also#
ScriptDelegate.add— registering, and what happens to a bad argument.ScriptDelegate.remove— unsubscribing, and why identity matters.globalEvents— the 21 table-wide instances.ObjectHandle— the 8 entity-scoped instances.- Events and delegates — the ordering rules, stated once.
Members#
| Signature | Description | Returns |
|---|---|---|
add(handler: (...args: TArgs) => void) |
Register a handler. Handlers run on the host, in registration order. | void |
remove(handler: (...args: TArgs) => void) |
Remove a previously registered handler. | void |
scriptdelegate.add#
add(handler: (...args: TArgs) => void): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Register a handler. Handlers run on the host, in registration order.
Registers a handler on this delegate. The handler is appended to the delegate's list along with a record of which script registered it, and it is called for every subsequent event — in the order it was added, relative to every other handler on the same delegate, whichever script owns them.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
handler |
(...args: TArgs) => void |
yes | The function to call. TArgs is the delegate's tuple, so the editor infers each argument's type for you. A non-function argument is ignored — add(undefined) returns silently, with no error and no diagnostic. A handler added twice is stored twice and called twice. |
How, why and when to use it
Registering handlers is the whole job of a script body: it runs once, synchronously, at start, and everything
after that is a callback. add at the top level of the script rather than inside another handler, so the
subscription exists before the first event can arrive. The alternative — registering lazily, the first time some
other event fires — is occasionally right (a handler that should only exist during a round) and otherwise
means the script misses whatever happened before that point. Pass a named function whenever you might want to
remove it later; an inline arrow can never be removed, because you have nothing left to pass to remove.
Example
// content/scripting-api/examples/scriptdelegate.add.ts
// Scene script: three handlers on one delegate. They run in the order they
// were added, and a handler that throws does not stop the ones after it.
function first(entity: ObjectHandle, context: EventContext): void {
world.log(`1. ${context.actor} dropped ${entity.name ?? entity.kind}.`);
}
function second(entity: ObjectHandle): void {
world.log(`2. ${entity.id} is at [${entity.position.join(", ")}] ft.`);
throw new Error("second handler fails on purpose");
}
function third(entity: ObjectHandle): void {
world.log(`3. still called after handler 2 threw (${entity.id}).`);
}
globalEvents.onObjectDropped.add(first);
globalEvents.onObjectDropped.add(second);
globalEvents.onObjectDropped.add(third);
// add() ignores anything that is not a function, so this is a no-op rather
// than an error - and a duplicate registration is kept, so the same handler
// added twice is called twice.
globalEvents.onObjectDropped.add(first);
world.log("Four registrations made on onObjectDropped.");
One drop prints four lines in registration order — 1. …, 2. …, 3. …, then 1. … again from the duplicate
registration — plus a handler-phase diagnostic for the deliberate failure in handler 2.
Gotchas
A handler can take fewer arguments than the delegate passes. JavaScript ignores the extras and TypeScript
accepts the narrower signature, so add((entity) => …) on a three-argument delegate is legal and often what you
want.
A duplicate registration is not deduplicated. Adding the same function reference twice puts two entries in
the list, and it is called twice per event. remove takes one of them off.
Registering from inside a handler takes effect on the next event. The fan-out in progress runs against a copy of the list taken before the first handler was called.
Adding a handler to onTick starts the host's tick timer, and it stays running until the last tick handler
is removed. No other delegate has a cost to subscribing.
See also
ScriptDelegate.remove— the matching call, and why identity matters.ScriptDelegate— multicast behavior and error isolation.globalEvents.onTick— the one delegate where subscribing has a cost.- Execution order — when a script body's
addcalls run. - Events and delegates — the ordering rules, stated once.
scriptdelegate.remove#
remove(handler: (...args: TArgs) => void): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Remove a previously registered handler.
Unregisters a handler. The delegate searches its list for the first entry whose function is the identical
reference and splices it out. Nothing else is compared — not the arguments, not the owning script — so the
function you pass must be the same object you passed to add.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
handler |
(...args: TArgs) => void |
yes | The exact reference to remove. A handler that is not registered is silently ignored — no error, no diagnostic, no return value to check. A function that was added twice needs two remove calls. |
How, why and when to use it
You want a one-shot: announce the first roll of the game and then stop announcing. Calling remove from inside
the handler is the standard shape, and it is the only way to make a subscription stop — there is no once
helper, no returned token, and no way to disable a delegate. The alternative is a boolean guard at the top of the
handler, which is simpler to write and leaves the handler being called forever; that is fine for a cheap check
and wrong for onTick, where removing the last handler is what actually stops the host sending ticks. Use
remove when the subscription has a real end; use a guard when you only want to skip the body.
Example
// content/scripting-api/examples/scriptdelegate.remove.ts
// Scene script: a one-shot handler. remove() matches on identity, so you must
// keep a reference to the exact function you added - an inline arrow can
// never be removed, because you have nothing to pass back in.
function announceFirstRoll(die: ObjectHandle, value: number | null, context: EventContext): void {
world.broadcast(`First roll of the game: ${die.name ?? die.id} by ${context.actor}.`);
world.log(`It landed on ${value === null ? "nothing readable" : String(value)}; unsubscribing.`);
globalEvents.onDiceRolled.remove(announceFirstRoll);
}
globalEvents.onDiceRolled.add(announceFirstRoll);
// Removing a handler that was never added is a no-op, not an error.
globalEvents.onDiceRolled.remove(announceFirstRoll);
globalEvents.onDiceRolled.remove(announceFirstRoll);
// Re-add it so the one-shot really is armed.
globalEvents.onDiceRolled.add(announceFirstRoll);
// Ticks stop being delivered when the last tick handler is removed.
function heartbeat(dt: number): void {
world.log(`tick dt=${dt}; switching ticks back off.`);
globalEvents.onTick.remove(heartbeat);
}
globalEvents.onTick.add(heartbeat);
world.log("One-shot roll announcer and one-shot heartbeat are armed.");
Within a tenth of a second the script console prints tick dt=0.1; switching ticks back off. and no further
ticks arrive. The first roll of the game puts one line in the table chat and never repeats.
Gotchas
An inline arrow function can never be removed. delegate.add(() => …) creates a function nobody holds a
reference to. If a subscription might end, declare it as a named function first.
Removing from inside a handler does not affect the fan-out in progress. The sandbox iterates a copy, so any handler after yours on the same delegate still runs for the current event.
It removes one entry, not all matches. A handler added twice is called twice until you remove it twice.
Removing the last onTick handler switches ticks off for the whole sandbox, across every running script —
the interest flag is per frame, not per script. Removing one of two leaves delivery on.
See also
ScriptDelegate.add— the matching call, and duplicate registrations.ScriptDelegate— multicast behavior and error isolation.globalEvents.onTick— where removing actually stops work happening.- Events and delegates — the ordering rules, stated once.
- Execution order — what a restart clears without any
removecall.
GlobalEvents#
Surface A — table script · interface · 24 members
Global events (all objects / table-wide).
Reached from a script as the injected global globalEvents.
declare const globalEvents: GlobalEvents;
Global events singleton.
globalEvents is the frozen singleton that carries the 21 table-wide delegates. It is injected into every table
script — scene scripts and object scripts alike — and it is how a script hears about something it is not already
holding a handle to. Eight of the delegates are about an entity and hand you a handle; one reports a finished
dice roll as a whole; four report a crossing — two for seat zones and two for trigger volumes authored on a
model — and the remaining eight are about the room: turns, players, seats, teams, chat and the throttled tick.
How, why and when to use it#
You are writing a scoring game and you need to know when any die on the table stops moving, not one die you
picked out at startup. globalEvents is the only way to hear about entities your script never looked up —
refObject covers exactly one entity, and polling world.getAllObjects on a timer costs a snapshot round-trip
per call and still misses everything that happened between two polls. Subscribe on globalEvents when the set of
entities you care about is open-ended or changes during play; subscribe on a specific ObjectHandle when you
already know which entity matters, because that saves you the id comparison at the top of every handler.
Gotchas#
One object, shared by every running script. globalEvents is created once per sandbox and frozen, so two
scripts that both call globalEvents.onObjectDropped.add(…) are adding to the same handler list. Registration
order across scripts therefore follows the boot order — scene scripts in sceneScriptIds order, then object
scripts — described in Execution order.
Eight delegates carry an EventContext and twelve do not. onTurnStarted, onTurnEnded, onPlayerJoined,
onPlayerLeft, onSeatChanged, onTeamChanged, onChatMessage and onTick hand your handler a single
argument with no context object, because the room, not a person, caused them; so do the four crossing delegates,
because a crossing says where something ended up and not who put it there. The eight entity delegates always pass
the context last.
A handler's return value is discarded. Nothing here can refuse, delay or alter what triggered it — by the time your handler runs the host has already applied the change and broadcast it. See Nothing can cancel an action.
globalEvents does not exist in a mod. Mod scripting subscribes with api.on("onTurnStart", fn) against a
separate list of 14 hooks. The near-miss is deliberate to notice: the mod hook is onTurnStart, this delegate is
onTurnStarted, and these are not the same event — different surface, different payload, no relationship.
See Mod hooks and capabilities.
See also#
ScriptDelegate— theadd/removepair every one of these is.EventContext— the actor argument eight of them carry.ObjectHandle— the eight entity-scoped twins of these delegates.- Events and delegates — scope, ordering, multiplicity and cancellation.
- Execution order — when your
addcalls actually happen. - Choosing a surface — why the mod hook list is a different list.
Members#
| Signature | Description | Returns |
|---|---|---|
onObjectCreated |
ScriptDelegate<[ObjectHandle, EventContext]> |
|
onObjectDestroyed |
The destroyed entity's id — it is already gone, so there is no handle. context.reason says why it went; for "absorbed" context.containerId names the stack that took it. |
ScriptDelegate<[string, EventContext]> |
onObjectPickedUp |
ScriptDelegate<[ObjectHandle, EventContext]> |
|
onObjectDropped |
ScriptDelegate<[ObjectHandle, EventContext]> |
|
onObjectAction |
ScriptDelegate<[ObjectHandle, ObservedObjectAction, EventContext]> |
|
onDiceRolled |
ScriptDelegate<[ObjectHandle, number | null, EventContext]> |
|
onDiceRollResult |
A whole batch roll finished and has been totalled. Fires once per roll, AFTER the per-die onDiceRolled events — use it when you need the total rather than a die. |
ScriptDelegate<[DiceRollSummary]> |
onCardDrawn |
The drawn card; context.containerId names the container it came from. |
ScriptDelegate<[ObjectHandle, EventContext]> |
onContainerShuffled |
ScriptDelegate<[ObjectHandle, EventContext]> |
|
onObjectEnteredContainer |
A PIECE went into a container anywhere on the table. The handle is the piece as it was just before the container took it; context.containerId names the container. |
ScriptDelegate<[ObjectHandle, EventContext]> |
onObjectLeftContainer |
A PIECE came out of a container — drawn, tipped out, or lifted out of a bowl. The handle is the piece now on the table; context.containerId names the container. |
ScriptDelegate<[ObjectHandle, EventContext]> |
onZoneEnter |
An entity entered a seat zone — any type, scripting included. Once per crossing. A tagFilter on the zone gates occupancy, so a filtered-out entity fires nothing. |
ScriptDelegate<[ZoneEvent]> |
onZoneLeave |
An entity left a seat zone: it moved out, it left the table, or the zone went away with a released seat. One event for all three. | ScriptDelegate<[ZoneEvent]> |
onTriggerEnter |
An entity entered a TRIGGER VOLUME authored on a model in the Model editor. Once per crossing. A trigger volume never collides and never affects physics — firing these events is the whole of what it does. | ScriptDelegate<[TriggerEvent]> |
onTriggerLeave |
An entity left a trigger volume: it moved out, or it left the table. Both are one event. | ScriptDelegate<[TriggerEvent]> |
onObjectMenuItem |
A player clicked one of this script's world.addObjectMenuItem entries. The handle is the entity the menu was opened on; the string is your own item id. |
ScriptDelegate<[ObjectHandle, string, EventContext]> |
onTurnStarted |
ScriptDelegate<[{ peerId: string; seat: string | null; team: string | null }]> |
|
onTurnEnded |
ScriptDelegate<[{ peerId: string }]> |
|
onPlayerJoined |
ScriptDelegate<[PlayerInfo]> |
|
onPlayerLeft |
ScriptDelegate<[PlayerInfo]> |
|
onSeatChanged |
ScriptDelegate<[{ peerId: string; seat: string | null }]> |
|
onTeamChanged |
ScriptDelegate<[{ peerId: string; team: string | null }]> |
|
onChatMessage |
ScriptDelegate<[{ peerId: string; displayName: string | null; text: string }]> |
|
onTick |
Throttled host tick (~10 Hz). Registering a handler enables delivery. | ScriptDelegate<[number]> |
globalevents.onObjectCreated#
readonly onObjectCreated: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when an entity comes into existence on the table. Two paths raise it: a spawn intent, from applyIntent
immediately after the dispatcher created the entity; and createGameplayObject, which every gameplay path that
produces an entity goes through — a card drawn off a deck, a card dealt to a seat, the pile a split moved off,
the stack a combine builds, and the card a one-card deck turns into
(apps/web/src/playcanvas/TabletopRuntime.ts). By the time your handler runs the entity exists in the host's
world and the handle you receive is filled from its real state.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The new entity. The sandbox refills this handle from the event's state before your handler runs, so id, kind, name, position, rotation, locked, faceUp, tags and metadata are current. |
| 2 | EventContext |
"Script" when a table script spawned it, "Host" for an internal path, otherwise the peer id of whoever acted ("You" only on a table with no peer id). |
Applies to: every object kind. The kind of the new entity changes nothing about whether the event fires.
How, why and when to use it
You want a budget — no more than a dozen extra pieces on the table, however they got there. onObjectCreated is
the one signal that the table gained an entity, wherever it came from: a player's Add menu, a mod's setup,
another script, or a deck someone drew from. The alternative most authors reach for is to do the bookkeeping
inside their own world.spawnObject call, which works right up until a second source produces something and
the count silently drifts. Use this delegate when you care that the table grew; keep the bookkeeping at the call
site only when your script is provably the only thing that creates entities.
It is also the moment an object script gets attached: an entity whose metadata.scriptId names one has that
script attached before this event is dispatched, so the new script's own onCreated fires too. See
Execution order.
Example
// content/scripting-api/examples/globalevents.onObjectCreated.ts
// Scene script: keep a running count of the entities that came into existence,
// so the table can refuse to grow past a budget. Spawns raise this, and so do
// the gameplay paths - a drawn card, a dealt card, a split, a combine.
const SPAWN_BUDGET = 12;
let spawnedThisSession = 0;
globalEvents.onObjectCreated.add((entity, context) => {
spawnedThisSession += 1;
world.log(`spawned #${spawnedThisSession}: ${entity.kind} ${entity.id} by ${context.actor}`);
if (spawnedThisSession > SPAWN_BUDGET) {
world.broadcast(`That is ${spawnedThisSession} spawned pieces - tidying up.`);
entity.destroy();
}
});
async function spawnMarker(index: number): Promise<void> {
const marker = await world.spawnObject({
kind: "token",
name: `marker-${index}`,
position: [index * 0.75 - 2, 1, 0]
});
if (!marker) {
world.log("spawnObject refused the request: kind must be a non-empty string.");
}
}
void spawnMarker(1);
void spawnMarker(2);
The script console prints two lines on start, for example
spawned #1: token script-1f2e3d4c5b6a7089 by Script and spawned #2: token script-90abcdef12345678 by Script.
Drawing a card off a deck afterwards prints a third.
Gotchas
By design. A table rebuilt from a snapshot raises nothing here. Every entity is recreated through the same low-level
createObjectthat both emitting paths call, and announcing there would fire "created" for the whole table on every snapshot apply (apps/web/src/playcanvas/TabletopRuntime.ts,createGameplayObject's comment). Read the event as "this has just come into existence", never "this is now present": seed any index fromworld.getAllObjects()when your script starts, and keep it current with the event. That is also what makes it correct after a reconnect, a load from a save, or a host migration. See Known limitations.
Fires once per entity, on the host only. A player's Add menu, a script's world.spawnObject and a mod's
api.createObject all produce exactly one spawn intent and therefore one call per handler. Handlers run in
registration order, table-wide delegate first and then the entity's own onCreated.
A draw raises this and onCardDrawn. The card's creation comes first, then the draw event, then
onObjectAction for the draw. A handler that counts entities and a handler that counts draws both fire; do not
treat one as a substitute for the other.
An object script's own entity does not trigger it when the table loads. The entity an object script is attached to at start already existed when the script host booted, so the "this entity is ready" moment is the top of the script body, not a handler. For an entity created while scripts are running, the attachment happens first and the handler does run.
See also
ObjectHandle.onCreated— the entity-scoped twin, useful on a handle you just spawned.world.spawnObject— the call that raises this event from a script.globalEvents.onCardDrawn— the draw event that follows it.- Execution order — when an object script is attached, and to what.
- Known limitations — the full list of documented gaps.
globalevents.onObjectDestroyed#
readonly onObjectDestroyed: ScriptDelegate<[string, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The destroyed entity's id — it is already gone, so there is no handle.
context.reason says why it went; for "absorbed" context.containerId
names the stack that took it.
Fires when an entity leaves the table. The runtime raises it from four paths, and
context.reason says which: "deleted" (the delete action in
applyObjectAction), "depleted" (removeDepletedDeck, when the last card is drawn off a deck), "converted"
(convertDeckToLastCard, when a one-card deck becomes a plain card) and "absorbed" (combineIntoStack and
mergeCardLikeIntoDeck, when a merge folds an entity into a stack). This is the only globalEvents delegate
that hands you an id instead of a handle, because the entity is already gone from the host's world by the
time it fires.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[string, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | string |
The destroyed entity's id. Never a handle, never null — the runtime always sends a non-empty id, and the sandbox coerces a missing one to "". |
| 2 | EventContext |
Who removed it, and why. actor is "Script" for a destroy() call, a peer id for a player, "Host" for an internal path, "You" on a solo table. reason is one of the four ObjectDestroyedReason values, and on "absorbed" containerId names the surviving stack. |
Applies to: every object kind. The kind the runtime records in its own payload is not passed through to
scripts, so if you need to know what kind of thing vanished, remember it while the entity is still there.
How, why and when to use it
A player deletes the deck your game is counting cards from, and every later world.getObjectById on it resolves
null with no explanation. onObjectDestroyed is where you clean up: drop the id from your own maps, close out
a score, or spawn a replacement. The alternative is to discover the loss lazily — check for null on the next
read — which works but pushes a null check into every code path and loses the moment it happened, so you cannot
announce it or attribute it. Use this delegate when the disappearance itself is an event in your game; rely on a
null from refresh() when you only need the current answer.
Example
// content/scripting-api/examples/globalevents.onObjectDestroyed.ts
// Scene script: this delegate hands you an id, not a handle - the entity is
// already gone. Remember what you will want to say about a piece while it is
// still on the table, then look it up by id when it disappears.
const labelById = new Map<string, string>();
async function rememberEverything(): Promise<void> {
const entities = await world.getAllObjects();
for (const entity of entities) {
labelById.set(entity.id, entity.name ?? entity.kind);
}
world.log(`Remembered ${labelById.size} entities.`);
}
globalEvents.onObjectCreated.add((entity) => {
labelById.set(entity.id, entity.name ?? entity.kind);
});
globalEvents.onObjectDestroyed.add((entityId, context) => {
const label = labelById.get(entityId);
labelById.delete(entityId);
world.log(label === undefined
? `${entityId} was removed by ${context.actor}, and this script never saw it.`
: `${label} (${entityId}) was removed by ${context.actor}.`);
});
void rememberEverything();
On a table with four entities the script console prints Remembered 4 entities. on start, and then one line per
removal, for example red-die (obj-1) was removed by You.
Gotchas
onObjectAction fires first for a delete. A delete action raises onObjectAction with the action string
"delete" and a handle still holding the entity's last known state, and only then raises this delegate. If you
need the entity's position or tags at the moment it went, read them in the onObjectAction handler.
A draw that empties a deck raises this without any delete. Drawing the last card off a deck removes the
deck (reason: "depleted"), and drawing the second-to-last converts the remaining one-card deck into a card and
removes the deck entity (reason: "converted"). Both paths raise onObjectDestroyed for the deck's id even
though nobody asked to delete anything.
A combine raises one of these per absorbed entity, and none for the survivor. Dropping a card onto a deck
raises it once for the card; dropping a card onto a card raises it twice and then raises onObjectCreated for
the new deck. Each carries reason: "absorbed" and a containerId naming the survivor, so a merge is
distinguishable from a deletion — before this, the merge paths announced nothing at all and a
lifetime-tracking script silently accumulated dead ids.
Absorbed is not "in the bin". The entity is gone but its cards are inside the survivor, and drawing one back
out creates a new entity with a new id. Reconnect on card identity (cardId / label), never on the
entity id you saw destroyed.
See also
ObjectDestroyedReason— the four causes, and the runtime path behind each.ObjectHandle.onDestroyed— the entity-scoped twin, which receives only the context.ObjectHandle.destroy— the call that raises this from a script.globalEvents.onObjectAction— the event that fires just before it.- Events and delegates — why this delegate's two scopes differ.
- Known limitations — the full list of documented gaps.
globalevents.onObjectPickedUp#
readonly onObjectPickedUp: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires the moment an entity is lifted off the table. Three code paths raise it: the host's applyIntent when a
remote player's drag intent starts a hold, the desktop grab tool when someone on the host machine picks
something up, and the VR grab bridge when a hand closes on a piece. The entity is in the air and moving when your
handler runs — its position is where it was picked up from, not where it will end up.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The entity being held. Refilled from the event's state just before your handler runs. For a parented assembly this is the escalated root, not the piece the player touched. |
| 2 | EventContext |
A peer id for a remote player's drag; "You" for a pickup made with the grab tool or a VR hand on the host machine. Never "Script" — a script has no way to pick anything up. |
Applies to: every object kind. Anything a player can grab raises it, including a board and a card-holder.
How, why and when to use it
You want to show whose hand a piece is in, or stop a second player grabbing something that is already moving.
This is the only event that tells you a hold started; onObjectDropped tells you it ended, and a position read
in between tells you nothing about who is responsible. The alternative most authors try is polling positions on
onTick and inferring movement, which fires ten times a second, cannot distinguish a throw from a carry, and
never gives you an actor. Use onObjectPickedUp when the act of holding matters — turn enforcement, "hands off
my hand", a highlight — and use onObjectDropped when only the final resting place does.
Example
// content/scripting-api/examples/globalevents.onObjectPickedUp.ts
// Scene script: track what each actor is currently holding. A pickup fires
// once per entity, so dragging a three-piece selection gives you three calls
// with the same actor.
const heldByActor = new Map<string, Set<string>>();
globalEvents.onObjectPickedUp.add((entity, context) => {
let held = heldByActor.get(context.actor);
if (!held) {
held = new Set<string>();
heldByActor.set(context.actor, held);
}
held.add(entity.id);
world.log(`${context.actor} lifted ${entity.name ?? entity.kind} (${held.size} in hand).`);
if (entity.locked) {
world.log(`${entity.id} reports locked - the grab escalated to an unlocked ancestor.`);
}
});
globalEvents.onObjectDropped.add((entity, context) => {
const held = heldByActor.get(context.actor);
if (held) {
held.delete(entity.id);
}
world.log(`${context.actor} released ${entity.name ?? entity.kind}.`);
});
world.log("Watching pickups and drops.");
Picking up one token prints You lifted red-token (1 in hand).; dragging a three-piece selection prints three
lines with counts 1, 2 and 3 before any drop line appears.
Gotchas
Once per entity, not once per gesture. Dragging a multi-entity selection raises one call for the primary piece and one for every other member of the selection, all with the same actor, in the order the runtime iterates the pickup list. A remote player's drag raises exactly one.
By design. Grabbing a member of a parented assembly escalates to the root ancestor, so this event reports the root and not the piece the player's cursor was over (
apps/web/src/playcanvas/TabletopRuntime.ts,resolveGrabTarget) — "I glued this token to its base, so moving it should move the base" is what parenting means to an author. Escalation stops at the first ancestor that is locked or that the actor may not drag, Alt+grab targets the clicked child on desktop, and VR has no Alt, so a headset grab always escalates. A child markedgrabbableWhileParentedin itsmetadatais exempt unless the assembly is welded or an ancestor is restricted. Comparehandle.idagainst the ids you actually care about rather than assuming the player touched what you were watching.
A script never appears as the actor. setPosition moves an entity without a hold, so a scripted move raises
no pickup and no drop at all — only onObjectAction for actions, and nothing for a transform.
See also
ObjectHandle.onPickedUp— the entity-scoped twin.globalEvents.onObjectDropped— the matching release.EventContext.actor— why"You"appears here and not a peer id.- Grab events report the assembly root — escalation, stated once, in full.
- Events and delegates — ordering and multiplicity.
globalevents.onObjectDropped#
readonly onObjectDropped: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires the moment a player lets go of an entity. Two code paths raise it: the host's applyIntent when a remote
player's drag intent arrives with release: true, and the desktop or VR grab tool when the local hold ends.
The entity has been released but has not finished falling or sliding — the position in the handle is where it was
let go, not where it will come to rest.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The released entity, refilled from the event's state. For a parented assembly this is the escalated root. |
| 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
You want "one move per turn": a player picks a piece up, puts it down, and that is their move spent. A drop is
the moment a human finished doing something deliberate, which makes it the natural place to charge a move,
validate a placement or snap a piece to a lane. The alternative authors reach for first is onObjectAction,
which fires for flips and locks as well and never fires for a plain drag; the other is onDiceRolled, which
answers a different question — that is about what a die landed on, this is about the fact that somebody moved
something. Use onObjectDropped for turn accounting and placement rules; use onObjectPickedUp when you need to
react while the piece is still in the air.
Example
// content/scripting-api/examples/globalevents.onObjectDropped.ts
// Scene script: "one move per turn". A drop is the moment a player finishes
// moving a piece, so it is the right event to spend a move on - onDiceRolled
// would tell you what a die landed on, which is a different question.
const movedThisTurn = new Set<string>();
globalEvents.onTurnStarted.add((turn) => {
movedThisTurn.clear();
world.log(`Turn started for ${turn.peerId}; move counter reset.`);
});
globalEvents.onObjectDropped.add((entity, context) => {
if (context.actor === "Script" || context.actor === "Host") {
return;
}
if (movedThisTurn.has(context.actor)) {
world.broadcast(`${context.actor} has already moved a piece this turn.`);
return;
}
movedThisTurn.add(context.actor);
const [x, y, z] = entity.position;
world.log(`${context.actor} moved ${entity.name ?? entity.kind} to [${x}, ${y}, ${z}] ft.`);
});
world.log("One-move-per-turn watcher is running.");
The first drop of a turn prints a line such as You moved red-token to [1.4, 0.35, -2.1] ft.; a second drop by
the same actor puts You has already moved a piece this turn. into the table chat instead.
Gotchas
Once per entity that still exists. Releasing a multi-entity selection raises one call per member — but only
for the members that survived the drag. A combine or a shuffle during the hold can consume one, and a consumed
piece raises onObjectDestroyed rather than a drop.
The position is the release point, not the resting place. Physics keeps running after the event. If your
rule depends on where the piece ends up, await world.wait(1) and then await handle.refresh().
By design. A drop on a parented assembly reports the root ancestor, not the child the player was dragging (
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 because a headset has no Alt. Comparehandle.idagainst the ids your rule is about rather than assuming the handle is the piece under the cursor.
A scripted move raises nothing. setPosition teleports without a hold, so a script correcting a placement
inside this handler does not re-enter it. That is what makes react-and-correct safe here.
See also
ObjectHandle.onDropped— the entity-scoped twin, with no id comparison to write.globalEvents.onObjectPickedUp— the start of the same hold.ObjectHandle.setPosition— how to correct a placement from the handler.- Nothing can cancel an action — why the pattern is react-and-correct.
- Events and delegates — ordering and multiplicity.
globalevents.onObjectAction#
readonly onObjectAction: ScriptDelegate<[ObjectHandle, ObservedObjectAction, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires for every object action the host applies, whoever asked for it. The runtime raises it at the end of
applyObjectAction, after the action has changed the entity and after the resulting snapshot has been emitted.
It fires in addition to the semantic event for that action, so a single roll gesture calls this delegate and
then onDiceRolled — though for a roll that last one does not arrive until the die stops, about a second later.
Parameters
The handler receives three arguments, transcribed from
ScriptDelegate<[ObjectHandle, ObservedObjectAction, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The entity the action was applied to, refilled from the event's state. For delete the entity is already gone, and the handle carries its last known state instead. |
| 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. Every action reaches the runtime whatever the entity is; what varies is
whether the runtime then does anything, which Action vocabularies sets out
per kind. The event fires either way — draw on a die raises onObjectAction and nothing else.
How, why and when to use it
You are writing an audit trail, or an anti-cheat rule that has to notice anything being done to a piece — a
card being flipped face-up when it should not be, a token being locked mid-turn. This is the only delegate that
sees all 19 engine actions in one place, including lift, flick, press and the three reveal-* actions that
no script can request. The alternative is subscribing to the three semantic delegates (onDiceRolled,
onCardDrawn, onContainerShuffled) and getting nothing at all for flips, rotations, locks or reveals. Use the
semantic delegate when you care about one specific thing; use onObjectAction when "something was done to this
piece" is the question.
Example
// content/scripting-api/examples/globalevents.onObjectAction.ts
// Scene script: audit every action the host applies. The action argument is an
// ObservedObjectAction - the 13 a script may request plus the 6 only the engine
// raises - so the switch keeps a default branch for the rest.
const actionCounts = new Map<string, number>();
globalEvents.onObjectAction.add((entity, action, context) => {
const seen = (actionCounts.get(action) ?? 0) + 1;
actionCounts.set(action, seen);
switch (action) {
case "flip":
world.log(`${context.actor} flipped ${entity.name ?? entity.kind}; faceUp is now ${String(entity.faceUp)}.`);
break;
case "delete":
world.log(`${context.actor} removed ${entity.id}; onObjectDestroyed fires next.`);
break;
case "roll":
world.log(`${context.actor} rolled ${entity.id}; onDiceRolled follows when it stops.`);
break;
default:
// Reached by lift, flick, press and the three reveal actions, none of
// which a script can request but all of which a player can perform.
world.log(`${context.actor} performed "${action}" on ${entity.id} (${seen} so far).`);
break;
}
});
world.log("Action audit is running.");
Flipping a card prints a1b2c3d4 flipped ace-of-spades; faceUp is now true. Lifting a piece with the grab tool
prints a1b2c3d4 performed "lift" on obj-3 (1 so far). — the default branch is what catches it.
Gotchas
Six of the names you can receive are ones you cannot request. lift, flick, press, reveal-all,
reveal-team-a and reveal-team-b are in the delegate's ObservedObjectAction argument and not in the
ObjectAction union the mutators take, which is why passing the argument straight back into a helper that
requests an action does not compile. Narrow first. See
ObservedObjectAction.
Keep the default branch even though the type is complete. The engine's action list and the declared union
are maintained by hand in two files (packages/shared/src/tableObjects.ts,
packages/shared/src/scripting.ts), so an action added to one before the other arrives at your handler as a
string the compiler did not predict. A never check would turn that into a build error for you and a silent miss
for a reader running the last published editor.
Ordering inside one action is fixed. onObjectAction fires before onDiceRolled, onContainerShuffled
and onObjectDestroyed for the same action, and after onCardDrawn — a draw creates the card and raises
onCardDrawn from inside the action, then falls through to this delegate at the end.
onDiceRolled is the one pairing that is not immediate. It fires when the die comes to rest, roughly a
second after this delegate, and it also fires for a die that tumbled with no action behind it at all — a
shake-throw, or a knock hard enough to spin. Do not treat a "roll" here as a promise that onDiceRolled is
about to arrive, or an onDiceRolled as proof that a "roll" action preceded it.
For delete, the handle addresses nothing. The entity has already been removed; the runtime passes its last
captured state so you can still read position, tags and metadata. Calling a mutator on it does nothing.
See also
ObjectHandle.onAction— the entity-scoped twin.ObservedObjectAction— the 19 names this delegate can deliver.ObjectAction— the 13 a script may request, and the 4 with no method.- Action vocabularies — 19 engine actions, 13 a script may request, 10 a mod may.
- Events and delegates — the per-action ordering table.
- Known limitations — the full list of documented gaps.
globalevents.onDiceRolled#
readonly onDiceRolled: ScriptDelegate<[ObjectHandle, number | null, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a die comes to rest after tumbling, carrying the number printed on the face it settled on. The
runtime raises it from the host's settle scan, roughly a second after the throw — not from applyObjectAction,
and not at the moment the impulse is applied.
Parameters
The handler receives three arguments, transcribed from
ScriptDelegate<[ObjectHandle, number | null, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The die that settled, refilled from the event's state — so its pose is the settled pose. |
| 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 the peer id of the player who rolled. A die that tumbled with no roll action behind it is attributed to "Host". |
There is a batch-level sibling. onDiceRollResult
fires once when a whole roll has settled, with the dice already totalled. Use this event when you care about
one die; use that one when you care about the throw.
Applies to: die. The value is read off the die's face table, so only a die can raise this. A card given
roll() is still launched with a die's impulse and still raises onObjectAction, but it has no faces and never
reaches this delegate.
How, why and when to use it
You want a die's result to score a round: a player throws it, it tumbles, and when it stops you add the number
to a total. That is exactly what this event now hands you — the die has stopped, and value is the face. The
alternative is onObjectDropped, which is the event most authors try first, but it fires when the die leaves
the player's hand, before the tumble, and a throw made from the object menu never produces a drop at all. Use
onObjectDropped when you care that a player threw something (turn tracking, "you already moved"); use
onDiceRolled when you care what a die landed on.
Example
// content/scripting-api/examples/globalevents.onDiceRolled.ts
// Scene script: score a round once both dice have come to rest. The event
// fires on the settle, not on the throw, so the number is already readable
// and there is nothing to wait for.
const results = new Map<string, number>();
globalEvents.onDiceRolled.add((die, value, context) => {
if (value === null) {
// A cocked die, or a custom die model with no face table.
world.broadcast(`${die.name ?? die.id} shows no readable value - roll it again.`);
return;
}
results.set(die.id, value);
world.log(`${context.actor} rolled ${value} on ${die.name ?? die.id}.`);
if (results.size >= 2) {
let total = 0;
for (const rolled of results.values()) {
total += rolled;
}
world.broadcast(`Round total: ${total}.`);
results.clear();
}
});
world.log("Round scorer is running.");
Throwing two dice prints a line per die as each one stops, then Round total: 11. in the table chat.
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, with value a literal null every time. It now fires on
the settle. Two consequences for existing scripts — it arrives about a second later than it used to, and it
lands after onObjectAction by that same second rather than immediately beside it.
A roll nobody asked for still fires. A die shaken by hand into a tumble, or knocked hard enough to spin,
settles into this event with context.actor === "Host" and no roll action anywhere. The event means "a die
rolled", not "a script or a player requested a roll".
A nudge is not a roll. A die that is pushed across the table without spinning updates the face it is showing and raises nothing here. Only a die that genuinely tumbled reaches this delegate.
null means unreadable, not unimplemented. A cocked die — one resting past half the angle between two of
its faces, where the winning face would be a coin toss between neighbours — reports null rather than a
confident guess, and so does a die imported as a custom model, which carries no face table. Ask for a re-roll;
do not substitute a number.
A batch roll raises this once per die AND onDiceRollResult once for the throw. Dice rolled from the table's
dice picker settle as a group: every die reaches this delegate as it stops, and then the whole batch reaches
onDiceRollResult with a total. Add to a score in
both places and you will count the roll twice.
onObjectAction fires first, with the action "roll" — and much earlier. One roll is still two handler
calls across the two delegates, but they are now about a second apart. If you are counting rolls in both places
you will double-count, and if you are pairing them up you cannot assume they arrive together.
See also
globalEvents.onDiceRollResult— the batch-level sibling, for the throw rather than the die.ObjectHandle.onRolled— the entity-scoped twin, for one die.ObjectHandle.roll— the call that raises this from a script.tableObjectDefinitionSchema.faceValue— the same number as it rides in the snapshot.- Events and delegates — the per-action ordering table.
- Known limitations — the full list of documented gaps.
globalevents.onDiceRollResult#
readonly onDiceRollResult: ScriptDelegate<[DiceRollSummary]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A whole batch roll finished and has been totalled. Fires once per roll, AFTER the
per-die onDiceRolled events — use it when you need the total rather than a die.
Fires once per roll, when every die in a batch has come to rest and the host has totalled them. It is the
batch-level sibling of onDiceRolled: that event fires
once per die, this one fires once for the whole throw and hands you a
DiceRollSummary — who rolled, where the dice landed, the notation,
every face, the total, and how many dice could not be read.
Parameters
The handler receives one argument, transcribed from ScriptDelegate<[DiceRollSummary]>:
| Position | Type | Notes |
|---|---|---|
| 1 | DiceRollSummary |
The finished batch: rollId, actorPeerId, actorName, seat, target, notation, dice, total, cocked. A plain record, not a handle — dice[i].objectId is how you reach the die itself. |
There is no EventContext third argument here, unlike most of globalEvents. The roller is already on the
payload as actorPeerId / actorName, so a context saying the same thing a second time would be one more place
for the two to disagree.
How, why and when to use it
Reach for this whenever the throw is the unit of meaning rather than the die: "the active player rolls 3d6 and
scores the total", "a roll of five 6s wins", "print one line in chat per roll". Written against onDiceRolled,
all three need you to know how many dice were in flight, hold partial results in a Map, decide when the batch
is finished, and sum it yourself — which is the whole of what the
onDiceRolled example does, and it still cannot tell two
players rolling at once apart. This event does the collecting for you and stamps the result with rollId and
actorPeerId, so concurrent rolls stay separate.
Use onDiceRolled instead when you care about one specific die — a die that is a marker, a counter or a
tracker, rather than a throw being scored — or when you want to react to a die that tumbled with no roll behind
it at all (a shake, a knock). Both fire for a batch roll, in that order: every onDiceRolled, then this.
Example
// content/scripting-api/examples/globalevents.onDiceRollResult.ts
// Scene script: announce a finished roll and score it per seat. This event
// fires ONCE per roll, after every per-die onDiceRolled in the same batch,
// with the dice already totalled - so there is nothing to collect and
// nothing to add up.
const scores = new Map<string, number>();
globalEvents.onDiceRollResult.add((result) => {
const faces = result.dice.map((die) => (die.value === null ? "?" : String(die.value)));
world.log(`${result.actorName} rolled ${result.notation} in the ${result.target}: ${faces.join(", ")}.`);
if (result.cocked > 0) {
// The table never invents a number it could not read: a cocked die reports
// null, adds nothing to the total, and is counted here instead.
world.broadcast(`${result.cocked} of ${result.dice.length} dice landed cocked - roll again.`);
return;
}
const seat = result.seat;
if (seat === null) {
world.broadcast(`${result.actorName} rolled ${result.total}, but holds no seat - nothing scored.`);
return;
}
const running = (scores.get(seat) ?? 0) + result.total;
scores.set(seat, running);
world.broadcast(`${result.actorName} rolled ${result.total} for ${seat}. Running total: ${running}.`);
});
world.log("Roll scorer is running.");
Rolling three d6 prints three onDiceRolled lines as each die stops, then one summary line with the total.
Gotchas
Both events fire, and double-counting is the easy mistake. A five-die roll raises onDiceRolled five times
and this once. If you add to a score in both handlers you will count the roll twice over. Pick the level you are
working at and stay there.
total excludes every unreadable die, and cocked says how many. A die that settles past the tilt the face
reader accepts, or a custom die model with no face table, reports value: null and contributes nothing. A
three-die roll with one cocked has dice.length === 3, cocked === 1 and a total of two dice. Check cocked
before treating total as the roll's answer — the table will not invent a number it could not read, and neither
should your script.
This is not raised by ObjectHandle.roll. Throwing one
existing die from a script or the entity menu is a per-die action and raises onObjectAction and onDiceRolled
only. This event belongs to a batch roll — the dice the roller asked the table for.
A roll can be nobody's. seat is null whenever the roller holds no seat, which includes a spectating host.
Key per-player state on actorPeerId, which is always present, and treat seat as a label.
notation groups by face count, not by preset. Two visually different six-siders read as 2d6, because a
player who rolled one of each expects 2d6 rather than 1d6+1d6. Do not parse it back into presets; read
dice[i].preset if you need to know which die was which.
Host-only, like every lifecycle event. The host's Ammo world decides what the dice settled on and the host dispatches the event; there is no second roll on a peer to disagree with it.
See also
globalEvents.onDiceRolled— the per-die sibling, fired first.DiceRollSummary— every field of the payload.ObjectHandle.roll— throwing one die from a script.manifest.dice— declaring which dice your game offers.- Events and delegates — the per-action ordering table.
globalevents.onCardDrawn#
readonly onCardDrawn: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The drawn card; context.containerId names the container it came from.
Fires when a card comes off a deck or a bag. The runtime raises it from drawCardFromDeck, after the new card
entity has been created and placed. The handle you receive is the new card; the container it came from is
context.containerId.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The drawn card, kind: "card", freshly created. metadata.cardId is its face, and metadata.sourceDeckId repeats the container id. |
| 2 | EventContext |
context.containerId names the deck or bag — this is the only event that carries it. context.actor is "Script" for a draw() call, or the peer id of whoever drew. |
Applies to: deck and bag. drawCardFromDeck returns immediately for every other kind, and for an empty
container, so no onCardDrawn fires — but onObjectAction with the action "draw" fires regardless.
How, why and when to use it
You need a hand limit: five cards per player, and the sixth goes back. onCardDrawn is the moment a card leaves
a container and becomes a piece somebody owns, and it is the only event that gives you the card and the pile
together. The alternatives answer narrower questions: onObjectAction with "draw" tells you the deck was drawn
from but hands you the deck, so you never learn which card came out or whether the draw produced one; and
onObjectCreated, which now fires for the drawn card just before this event, tells you the table grew without
telling you why. Use onCardDrawn when the card and its source are the subject.
Example
// content/scripting-api/examples/globalevents.onCardDrawn.ts
// Scene script: deal-limit enforcement. The handle you receive is the NEW
// card; the deck it came from is context.containerId. onObjectCreated fires
// for the card first, so count draws here rather than there.
const drawnByActor = new Map<string, number>();
const DRAW_LIMIT = 5;
globalEvents.onCardDrawn.add((card, context) => {
const drawn = (drawnByActor.get(context.actor) ?? 0) + 1;
drawnByActor.set(context.actor, drawn);
world.log(`${context.actor} drew ${card.name ?? card.id} (${drawn} of ${DRAW_LIMIT}).`);
if (drawn > DRAW_LIMIT) {
// Nothing can cancel the draw, so correct it afterwards instead.
world.broadcast(`${context.actor} is over the ${DRAW_LIMIT}-card limit; returning that card.`);
card.destroy();
}
});
globalEvents.onTurnEnded.add((turn) => {
drawnByActor.delete(turn.peerId);
world.log(`Draw count cleared for ${turn.peerId}.`);
});
world.log("Draw-limit watcher is running.");
Each draw prints a line such as a1b2c3d4 drew ace-of-spades (1 of 5).; the sixth draw of a turn puts a message
in the table chat and removes the card again.
Gotchas
The entity-scoped twin is on the container, not on the card. ObjectHandle.onCardDrawn is routed by
context.containerId, so deck.onCardDrawn fires for every card that deck produces. That is the delegate to
reach for in an object script on a deck; this one is for watching several containers at once. See
ObjectHandle.onCardDrawn.
context.containerId is string | null and only present here. It is undefined on every other event and
null if the runtime named no container, so handle both. See
EventContext.containerId.
A draw can remove the deck in the same gesture. Drawing the last card removes the emptied deck, and drawing
down to one card converts the remainder into a plain card and removes the deck entity — so
world.getObjectById(context.containerId) can resolve null on the very draw that reported it.
Ordering, exactly. For one draw action: onObjectCreated for the new card, then onCardDrawn, then
onObjectDestroyed if the deck was consumed, then onObjectAction with the action "draw".
See also
ObjectHandle.draw— the call that raises this from a script.ObjectHandle.onCardDrawn— the container-scoped twin.EventContext.containerId— the container id, and what its three values mean.globalEvents.onObjectDestroyed— the deck disappearing under the draw.- Events and delegates — the per-action ordering table.
globalevents.onContainerShuffled#
readonly onContainerShuffled: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a shuffle action is applied. The runtime raises it from applyObjectAction after the container's
contents have already been reordered with a host-private seed, immediately following onObjectAction. The new
order is not visible to a script at all — a table script cannot read a container's contents — so this event tells
you that a shuffle happened, never what came out of it.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ObjectHandle, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The container that was shuffled, refilled from the event's state. |
| 2 | EventContext |
"Script" for a shuffle() call, "You" for the local UI or a shake gesture, or the peer id of the player who shuffled. |
Applies to: deck, and only deck. shuffleObject reseeds the deck's card entries with a host-private
seed, and a deck holding fewer than two cards is left alone (it 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 event
never fires for one. A bag draws at random and has no order to shuffle; if you are watching for "the player
rummaged in the bag", this is not the event, and there isn't one yet.
How, why and when to use it
Somebody keeps re-shuffling the draw pile between draws, hunting for a card. This event is where you notice —
count shuffles per turn, announce them so the table can see, lock the deck after the second. The alternative is
onObjectAction filtered to "shuffle", which gives you the same information with an extra string comparison
and no advantage; use this delegate when shuffling is the thing you care about, and onObjectAction when you are
auditing everything at once. There is no way to prevent a shuffle, so anything you build here is a reaction.
Example
// content/scripting-api/examples/globalevents.onContainerShuffled.ts
// Scene script: announce a shuffle and stop players re-shuffling a deck to
// fish for a card. The event fires after the host has already reordered the
// contents, so this counts shuffles rather than preventing them.
const shufflesById = new Map<string, number>();
const SHUFFLE_LIMIT = 2;
globalEvents.onContainerShuffled.add((container, context) => {
const count = (shufflesById.get(container.id) ?? 0) + 1;
shufflesById.set(container.id, count);
world.broadcast(`${context.actor} shuffled ${container.name ?? container.kind}.`);
if (count > SHUFFLE_LIMIT) {
world.broadcast(`${container.name ?? container.id} has been shuffled ${count} times - locking it.`);
container.lock();
}
});
globalEvents.onTurnStarted.add((turn) => {
shufflesById.clear();
world.log(`Shuffle counts cleared for ${turn.peerId}'s turn.`);
});
world.log("Shuffle watcher is running.");
Each shuffle puts You shuffled draw-pile. in the table chat; the third shuffle of a turn adds a second chat
line and locks the deck.
Gotchas
The order is host-private and stays that way. The shuffle uses a seed only the host holds, and a table
script has no method that reads a container's contents. Model what you need to know — how many cards are left,
which have been drawn — from onCardDrawn rather than from the container.
A shake gesture shuffles too. Shaking a deck at the table applies the same shuffle action, so this fires
for gestures as well as menu clicks and script calls, with "You" or a peer id as the actor.
onObjectAction fires first, with the action "shuffle". One shuffle is two handler calls across the two
delegates.
Locking a container does not stop a script shuffling it. lock() restricts players; the host — including
every table script — bypasses that gate. The example's lock is a signal to the table, not an enforcement.
See also
ObjectHandle.onShuffled— the entity-scoped twin, for one deck.ObjectHandle.shuffle— the call that raises this from a script.globalEvents.onCardDrawn— the only event that reports what left a container.- Action vocabularies — what
shuffledoes on each kind. - Events and delegates — the per-action ordering table.
globalevents.onObjectEnteredContainer#
readonly onObjectEnteredContainer: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A PIECE went into a container anywhere on the table. The handle is the piece as it was
just before the container took it; context.containerId names the container.
Subscribe here when the rule is about the container ("the bowl is full", "that tile is
out of play"); subscribe on BagObject.onObjectEntered when it is about one container
you already hold.
A piece went into a container anywhere on the table. The handle is the piece as it was just before the
container took it, and context.containerId names the container that took it.
It is raised for both container forms: a "bag", where the piece stops being an entity and becomes a stored
run, and a "holder" — an open bowl or tray — where the piece simply comes to rest inside and stays an entity.
Parameters
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The piece, captured immediately before it entered. |
| 2 | EventContext |
context.actor is who put it in. context.containerId is always a real id on this event. |
How, why and when to use it
Subscribe here when the rule is about containers in general — "the bowl is full", "that tile is out of play", an audit line for everything that leaves the board. One scene-script handler covers every container on the table, including ones spawned later, which no per-container subscription can.
Subscribe on BagObject.onObjectEntered instead
when the rule is about one container and you would otherwise be filtering every event by id.
Example
// content/scripting-api/examples/globalevents.onObjectEnteredContainer.ts
// Scene script. One handler for every container on the table - subscribe here
// when the rule is about containers in general, and on
// `BagObject.onObjectEntered` when it is about one container in particular.
const storedByContainer = new Map<string, number>();
globalEvents.onObjectEnteredContainer.add((piece, context) => {
// On this event `containerId` is always a real id; the `?? ""` is only there
// because the field is declared `string | null | undefined` for the events
// that do not carry one.
const containerId = context.containerId ?? "";
const count = (storedByContainer.get(containerId) ?? 0) + 1;
storedByContainer.set(containerId, count);
world.log(
`${context.actor} put ${piece.name ?? piece.id} into ${containerId} (${count} so far).`
);
});
world.log("Container watch is running.");
Every piece that goes into any container is logged with a running tally per container.
Gotchas
⚠ For a "bag" the handle is already dead. The entity ceased to exist on the table as it went in, so read
what you need off the handle synchronously — refresh() will not answer and
world.getObjectById resolves null. A "holder"'s piece
stays live.
containerId is declared optional, and strict makes you handle it. The field is
containerId?: string | null because most events do not carry one. On this event it is always a real id, but
the compiler does not know that.
Cards are a different lane. A card going into a card bag is not reported here. A container holds cards or pieces, never both.
A refused drop raises nothing. The piece never entered, so there is no event — it glides back to where the hold started.
See also
globalEvents.onObjectLeftContainer— the other direction.BagObject.onObjectEntered— the per-container version.EventContext.containerId— the id it carries.BagObject.form— why a holder behaves differently.
globalevents.onObjectLeftContainer#
readonly onObjectLeftContainer: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A PIECE came out of a container — drawn, tipped out, or lifted out of a bowl. The handle
is the piece now on the table; context.containerId names the container.
⚠ Cards are the other lane: a card leaving a deck or a bag raises onCardDrawn, not
this. A container holds one or the other, never both.
A piece came out of a container anywhere on the table — drawn, tipped out, or lifted out of a bowl. The
handle is the piece now on the table, and context.containerId names the container
it left.
For a "bag" the piece is a newly created entity with a new id; the one that went in is gone. For a
"holder" it is the same entity, simply out of the bowl.
Parameters
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The piece, live and on the table. |
| 2 | EventContext |
context.actor is who took it. context.containerId is always a real id on this event. |
How, why and when to use it
This is the single place a scene script can see every piece that enters play, whichever container it came from and by whatever route. Tag it, place it, record who drew it, start the turn timer — all of it in one handler that also covers containers spawned after the script started.
Subscribe on BagObject.onObjectLeft instead when the
rule belongs to one particular bag.
Example
// content/scripting-api/examples/globalevents.onObjectLeftContainer.ts
// Scene script. Every piece that comes out of any container - drawn, tipped out
// or lifted out of a bowl - arrives here, already a real entity on the table.
globalEvents.onObjectLeftContainer.add((piece, context) => {
world.log(`${context.actor} took ${piece.name ?? piece.id} out of ${context.containerId ?? ""}.`);
// The handle is a live entity, so it can be placed, tagged or acted on here.
piece.setPosition([0, 1, 0]);
});
// CARDS are not reported here. A card leaving a deck or a card bag raises
// `onCardDrawn` instead, and a container holds one lane or the other, never
// both - so the two subscriptions never see the same event.
globalEvents.onCardDrawn.add((card, context) => {
world.log(`${context.actor} drew ${card.name ?? card.id} from ${context.containerId ?? ""}.`);
});
Every piece out of any container is logged and repositioned; the second handler shows where the card lane goes instead.
Gotchas
⚠ Cards do not come through here. A card leaving a deck or a card bag raises
onCardDrawn. A container holds one lane or the other, never both, so a rule that
must cover everything leaving any container needs both subscriptions.
The id is new, every time, for a bag. An id you recorded when the piece went in will never match the one
that comes out. Match on ContainerItem.key or a tag.
containerId is declared optional, and strict makes you handle it. The field is
containerId?: string | null because most events do not carry one; on this event it is always a real id.
It is not a substitute for knowing what came out. The event tells you a piece left; a script that needs the
piece it specifically asked for should read the handle
takeObject resolves with.
See also
globalEvents.onObjectEnteredContainer— the other direction.BagObject.onObjectLeft— the per-container version.globalEvents.onCardDrawn— the card lane.EventContext.containerId— the id it carries.
globalevents.onZoneEnter#
readonly onZoneEnter: ScriptDelegate<[ZoneEvent]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
An entity entered a seat zone — any type, scripting included. Once per crossing.
A tagFilter on the zone gates occupancy, so a filtered-out entity fires nothing.
Fires when an entity comes to be inside a seat zone that it was not inside a moment ago. The host recomputes which entities are standing in which zones once per frame and reports the difference, so you get exactly one event per crossing — not one per frame the entity spends in there.
It fires for every zone type, scripting included, and for every entity on the table.
Parameters
The handler receives one ZoneEvent:
| Field | Type | Notes |
|---|---|---|
zoneId |
string |
The authored zone id, unique only within its seat. |
zoneType |
ZoneType |
Branch on this first. |
seat |
string |
The seat that owns the zone. |
objectId |
string |
The entity that entered. Not a handle. |
How, why and when to use it
This is the event for "something arrived somewhere that matters" — a card reaching a hand, a piece landing on a
player's board, a token entering a region your rules care about. It is strictly better than watching
onObjectDropped and testing coordinates yourself,
for two reasons: it uses the same containment maths every other zone rule uses, so your rule and the engine can
never disagree about where the boundary is; and it also catches entities that arrive without anybody dropping
them — pushed by physics, moved by another script, or spawned in place.
Reach for onObjectDropped instead when you specifically need to know who acted, since a zone event carries
no actor.
Example
// content/scripting-api/examples/globalevents.onZoneEnter.ts
// Scene script: keep a live count of what is standing in every seat zone, and
// announce anything that reaches a hand zone. Zone events are evaluated on the
// host and dispatched there only, so this tally is authoritative by
// construction - no peer is running a second copy of it.
const occupancy = new Map<string, number>();
// A zone id is unique only WITHIN its seat, so both halves make the key.
function zoneKey(event: ZoneEvent): string {
return `${event.seat}/${event.zoneId}`;
}
globalEvents.onZoneEnter.add((event) => {
const key = zoneKey(event);
occupancy.set(key, (occupancy.get(key) ?? 0) + 1);
if (event.zoneType === "hand") {
world.log(`${event.objectId} reached ${event.seat}'s hand zone.`);
}
});
globalEvents.onZoneLeave.add((event) => {
const key = zoneKey(event);
occupancy.set(key, Math.max((occupancy.get(key) ?? 0) - 1, 0));
});
globalEvents.onTurnStarted.add((turn) => {
for (const [key, count] of occupancy) {
world.log(`${key}: ${count} at the start of ${turn.peerId}'s turn.`);
}
});
The console prints one line each time something reaches a hand zone, and a per-zone census at the start of every turn.
Gotchas
A zone's tagFilter gates occupancy itself. An entity whose tags the filter rejects is not in the zone
as far as the engine is concerned, so it raises nothing at all — no enter, no leave, and it is absent from
getZoneObjects. That is the same rule the hand, area and hidden behaviours use, and it is the cheapest way to
scope a rule to "only my scoring tokens".
Containment is two-dimensional. A seat zone is a footprint on the table plane with no height, so an entity lifted high above one is still inside it and a card resting on a board over one counts too. Zones deliberately have no ceiling; every rule that reads them agrees on that.
Nothing is filtered out for you. Locked furniture, boards and card holders standing inside a zone raise
crossings like anything else. Filter on kind or use a tagFilter — the events do not second-guess you,
precisely so that they and getZoneObjects always describe the same set.
Host only. Zone membership is evaluated on the authoritative peer and nowhere else, which is what makes it safe to keep authoritative state in the handler. Table scripts already run only there, so this costs you nothing — but do not expect the mirror-image mod hook to fire on a player's client.
See also
globalEvents.onZoneLeave— the matching exit.ZoneEvent— the payload, field by field.globalEvents.onObjectDropped— when you need the actor.- Zones and seats — authoring the zones this reports on.
globalevents.onZoneLeave#
readonly onZoneLeave: ScriptDelegate<[ZoneEvent]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
An entity left a seat zone: it moved out, it left the table, or the zone went away with a released seat. One event for all three.
Fires when an entity that was inside a seat zone is no longer inside it. Exactly one event per exit, off the
same once-a-frame membership pass that raises
onZoneEnter, and for every zone type.
Three different things produce it, and they are deliberately one event: the entity moved out, the entity left the table, or the zone itself went away because its seat was released.
Parameters
The handler receives one ZoneEvent — the same four fields
onZoneEnter carries, describing the zone that was left and the entity that left it.
How, why and when to use it
Pair it with onZoneEnter whenever you keep a count, a highlight or a per-zone summary: enter increments,
leave decrements, and because the host diffs the membership set for you the two are guaranteed to balance
without any bookkeeping of your own. It is also the moment to release anything you attached on entry — a
marker, a saved note, a pending timer.
The one thing it is not is a deletion notice. Use
onObjectDestroyed when you care that an entity
left the table, and read this as "it is no longer here", whatever "here" stopped meaning.
Example
// content/scripting-api/examples/globalevents.onZoneLeave.ts
// Scene script: a leave has three causes - the entity moved out of the zone,
// the entity left the table, or the seat was released and took its zones with
// it. One event covers all three, and resolving the id is how you tell the
// first from the other two.
globalEvents.onZoneLeave.add((event) => {
if (event.zoneType !== "hidden") {
return;
}
// A table zone has no owning seat; report it as belonging to the table.
void reportExit(event.seat ?? "table", event.objectId);
});
async function reportExit(seat: string, objectId: string): Promise<void> {
const entity = await world.getObjectById(objectId);
if (entity === null) {
// Gone from the table entirely - there is nothing left to inspect.
world.log(`Something left ${seat}'s hidden zone and is no longer on the table.`);
return;
}
const facing = entity.faceUp === true ? "face up" : "face down";
world.log(`${entity.name ?? entity.kind} left ${seat}'s hidden zone ${facing}.`);
}
The console names the entity when it is still on the table, and says so plainly when it is not.
Gotchas
A released seat empties its zones all at once. A seat's zones exist only while the seat is claimed, so standing up raises a leave for every entity that was inside every one of that seat's zones — with the vanished zone's real type, not a guess. Handlers that assume a leave means "somebody moved something" will fire a burst here.
The entity may no longer exist. world.getObjectById
resolves null when the leave was caused by the entity leaving the table. Handle that branch; it is the normal
case for a drawn card or an absorbed stack, not an error.
A tagFilter that stops matching does not raise a leave for a reason you can see. Occupancy is containment
and the filter, so an entity whose tags change to no longer match the zone leaves it — correctly, but
invisibly if you were only thinking about movement.
Host only, like every zone rule. A table script already runs nowhere else.
See also
globalEvents.onZoneEnter— the matching entry.ZoneEvent— the payload.globalEvents.onObjectDestroyed— when the question is really "did it leave the table".- Zones and seats — why a seat's zones come and go.
globalevents.onTriggerEnter#
readonly onTriggerEnter: ScriptDelegate<[TriggerEvent]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
An entity entered a TRIGGER VOLUME authored on a model in the Model editor. Once per crossing. A trigger volume never collides and never affects physics — firing these events is the whole of what it does.
Fires when an entity comes to be inside a trigger volume — a shape a model author placed on a model in the Model editor — that it was not inside a moment ago. The host samples which entities stand in which volumes on a fixed low-rate pass and reports the difference, so you get exactly one event per crossing, not one per sample the entity spends in there.
A trigger volume never collides and never affects physics. Raising this event is the entire whole of what it does — and it only does anything if a table script or a mod subscribes. An authored volume with no subscriber is inert.
Parameters
The handler receives one TriggerEvent:
| Field | Type | Notes |
|---|---|---|
triggerId |
string |
The authored volume's id, unique only within its model asset. |
triggerName |
string |
The authored volume's name. For printing, not for matching. |
triggerTag |
string (optional) |
The author's tag, when they set one. The thing to match on. |
ownerObjectId |
string |
The entity carrying the volume. With triggerId, the unique key for one volume. |
objectId |
string |
The entity that crossed into the volume. A different entity, and not a handle. |
phase |
"enter" | "leave" |
"enter" here, always. It exists so one handler can serve both delegates. |
⚠ ownerObjectId and objectId are two different entities. The first is the board; the second is the piece
that reached it.
How, why and when to use it
This is the event for "a piece reached a place the model defines" — a slot on a board, a scoring cup, the
inside of a track, a region an author drew rather than one a seat owns. It is the model-authored counterpart to
onZoneEnter, and the choice between the two is the
choice between geometry that belongs to a seat and geometry that belongs to a model: a seat zone
travels with whoever claims the seat, while a trigger volume travels with the entity it was authored on.
Match on triggerTag, not on triggerName. A tag is the
identifier the author added for your rule; a name is a label they can rename at any time without realising
anything depended on it.
Example
// content/scripting-api/examples/globalevents.onTriggerEnter.ts
// Scene script: react when a piece crosses a trigger volume that a model author
// placed and tagged "goal", and keep a live count of what is inside each one.
//
// Both delegates are evaluated and dispatched on the host and nowhere else, so
// this tally is authoritative by construction - no other peer runs a second copy
// of it, and no other peer is told about the crossing at all.
// A triggerId is unique only WITHIN a model asset, so two copies of the same
// board both call their volume "goal-slot". The volume's OWNER and its id
// together are what identify ONE volume on the table.
const occupants = new Map<string, Set<string>>();
function inside(event: TriggerEvent): Set<string> {
const key = `${event.ownerObjectId}/${event.triggerId}`;
const existing = occupants.get(key);
if (existing !== undefined) {
return existing;
}
const created = new Set<string>();
occupants.set(key, created);
return created;
}
globalEvents.onTriggerEnter.add((event) => {
// An untagged volume carries no tag at all, so match on the tag rather than on
// triggerName - a name is a label an author can rename without noticing.
if (event.triggerTag !== "goal") {
return;
}
inside(event).add(event.objectId);
void announce(event);
});
globalEvents.onTriggerLeave.add((event) => {
if (event.triggerTag !== "goal") {
return;
}
inside(event).delete(event.objectId);
world.log(`${inside(event).size} still inside ${event.triggerName}.`);
});
// Two ids, two different entities: ownerObjectId CARRIES the volume and objectId
// CROSSED it. Resolve either only when the rule cares about what it is. This
// fires for face-down and hidden pieces too, with the same payload.
async function announce(event: TriggerEvent): Promise<void> {
const crossed = await world.getObjectById(event.objectId);
const who = crossed === null ? event.objectId : crossed.name ?? crossed.kind;
world.log(`${who} reached ${event.triggerName} (${inside(event).size} inside).`);
}
The console names each entity as it reaches the goal volume and reports the remaining count as pieces leave.
Gotchas
Host only. The host is the only peer that evaluates trigger membership, so a handler registered on a player or spectator peer never runs. A table script already runs nowhere but the host, so this costs you nothing here — but do not expect the mirror-image mod hook to fire on a player's client either.
It fires for hidden and face-down entities, with the same payload. A card in a hand or inside a seat's
hidden zone crosses a trigger volume like anything else, and you are told its id. That is deliberate and it
is the same treatment onZoneEnter already gives hidden zones: on the host, redaction is a wire-only
transform, so a table script has always seen the unredacted table. It is not a new disclosure.
triggerId is not a key on its own. It is authored on the model asset, so two copies of the same board both
report "goal-slot". The unique identity of one volume is
ownerObjectId plus triggerId. A map keyed on
triggerId alone looks correct with one board on the table and silently merges every board the moment an author
adds a second.
Containment is tested at the crossing entity's origin point, exactly as seat-zone membership is. A large piece is inside a volume when its origin is, not when its geometry overlaps.
A volume is not persisted and not replicated. It is rebuilt from the model's authored configuration on whatever peer loads the model, and only the host's copy is ever sampled. Nothing about it appears in a snapshot.
See also
globalEvents.onTriggerLeave— the matching exit.TriggerEvent— the payload, field by field.globalEvents.onZoneEnter— the same shape of event for seat-owned geometry.onTriggerEnteron the mod surface — the same crossings, and the capability a mod needs.- Sidecars — the
triggerskey that authors these volumes.
globalevents.onTriggerLeave#
readonly onTriggerLeave: ScriptDelegate<[TriggerEvent]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
An entity left a trigger volume: it moved out, or it left the table. Both are one event.
Fires when an entity that was inside a trigger volume is no longer inside it. Exactly one event per exit,
off the same low-rate membership pass that raises
onTriggerEnter.
Two different things produce it, deliberately as one event: the entity moved out of the volume, or the entity left the table altogether. A volume also stops reporting when the entity carrying it leaves the table, because the volume goes with it.
Parameters
The handler receives one TriggerEvent — the same five fields
onTriggerEnter carries, describing the volume that was left and the entity that left it, with
phase set to "leave".
How, why and when to use it
Pair it with onTriggerEnter whenever you keep a count, a highlight or a per-volume summary: enter increments,
leave decrements, and because the host diffs the membership set the two balance with no bookkeeping of your own.
It is also the moment to release whatever you attached on entry.
The one thing it is not is a deletion notice. Read it as "it is no longer in there", whatever stopped being
true, and use
globalEvents.onObjectDestroyed when the question
is really whether the entity left the table.
Example
The onTriggerEnter example subscribes to both
delegates in one script, because a count is only correct if both halves are written together — it is the
example for this entry too. There is deliberately not a second, near-identical copy of it here.
Gotchas
Host only, exactly like the entry event and like every zone rule. A table script already runs nowhere else.
The entity may no longer exist. world.getObjectById
resolves null when the leave was caused by the entity leaving the table — the normal case for a drawn card or
an absorbed stack, not an error. Decrement your count from the id and skip the lookup when you can.
An entity that leaves the table raises the leave; an entity whose volume leaves the table may not. The membership pass reports a transition it can still see. Do not treat a balanced enter/leave pair as guaranteed across a model being deleted mid-game — re-derive from the ids you hold rather than trusting the count alone after a deletion.
It fires for hidden and face-down entities, with the same payload, for the same reason
onTriggerEnter does.
See also
globalEvents.onTriggerEnter— the matching entry, the worked example, and the authority rule in full.TriggerEvent— the payload.globalEvents.onZoneLeave— the seat-owned equivalent, which has a third cause this one does not.globalEvents.onObjectDestroyed— when the question is really "did it leave the table".
globalevents.onObjectMenuItem#
readonly onObjectMenuItem: ScriptDelegate<[ObjectHandle, string, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A player clicked one of this script's world.addObjectMenuItem entries. The handle is
the entity the menu was opened on; the string is your own item id.
Fires on the HOST wherever the click happened — a peer's click travels back as an intent.
The item's match is re-checked here, so this never fires for an entity the item does
not apply to.
Fires when a player clicks one of the context-menu entries this script registered with
world.addObjectMenuItem. It is the other half of that
call: the registration says what appears and where, this says what happens.
Parameters
| Parameter | Type | Notes |
|---|---|---|
object |
ObjectHandle |
The entity the menu was opened on. |
itemId |
string |
Your id, exactly as you registered it. |
context |
EventContext |
context.actor is the peer who clicked. |
How, why and when to use it
One handler usually serves every entry a script registers — switch on itemId. The entity arrives as a full
handle, so the handler can read it and mutate it without a lookup first.
This is also where conditions that match cannot express belong. match is a static filter evaluated on
every peer; this handler runs on the host with the whole table in reach, so "only on the back rank", "only on
your own turn" and "only if the deck still has cards" are all tests to make here. Declining is just returning —
say why with world.log so the player is not left wondering why nothing
happened.
Example
// content/scripting-api/examples/globalevents.onObjectMenuItem.ts
// Scene script: one handler serving every menu entry this script registers.
//
// Switch on the item id - the second argument is exactly the `id` you passed to
// addObjectMenuItem, so a `switch` here is the whole dispatch.
world.addObjectMenuItem({ id: "reveal", label: "Reveal to table", match: { kinds: ["card"] } });
world.addObjectMenuItem({ id: "bury", label: "Bury", match: { kinds: ["card"] }, danger: true });
globalEvents.onObjectMenuItem.add((object, itemId, context) => {
// Conditions over GAME STATE belong here, not in `match`. `match` is a static
// filter evaluated on every peer; this handler runs on the host and can see
// the whole table, including who is holding what.
if (object.locked) {
world.log(`${object.name ?? "That piece"} is locked.`);
return;
}
if (itemId === "reveal") {
object.flip();
world.broadcast(`${context.actor} revealed ${object.name ?? "a card"}.`);
return;
}
if (itemId === "bury") {
// `context.actor` is whoever CLICKED, not the entity's owner - anyone who can
// open the menu can click, so restrict a verb here if it belongs to one seat.
world.broadcast(`${context.actor} buried ${object.name ?? "a card"}.`);
object.destroy();
}
});
See world.addObjectMenuItem for the
registration side of the same pair.
Gotchas
It fires on the host, wherever the click happened. A player's click travels to the host as an intent and your handler runs there — which is the only peer your script is running on at all.
The item's match is re-checked before you are called. A peer's view of an entity can be stale, and an
intent can be forged, so the host decides again whether the item really applies to that entity, using the same
predicate the peer drew the menu with. You will never be handed an entity the item does not match.
context.actor is the clicker, not the entity's owner. Anyone who can open the menu can click the entry. If
a verb should belong to one seat, check that here.
See also
world.addObjectMenuItem— register the entry, with a full example.refObject.onMenuItem— the same click, narrowed to one entity.globalEvents.onObjectAction— the built-in menu actions, which a script observes rather than defines.
globalevents.onTurnStarted#
readonly onTurnStarted: ScriptDelegate<[{ peerId: string; seat: string | null; team: string | null }]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when turn order hands the table to a player. The app raises it from two places: when turn order is first switched on and the first player is chosen, and each time the turn advances to the next player in the order. It does not fire when turn order is switched off, and it does not fire on a table that never turns it on.
Parameters
The handler receives one argument, transcribed from
ScriptDelegate<[{ peerId: string; seat: string | null; team: string | null }]>:
| Field | Type | Notes |
|---|---|---|
peerId |
string |
The peer whose turn has begun. Always a real peer id — the app skips the event entirely when there is no next player. |
seat |
string | null |
That peer's seat at the moment the turn began, read from the room's assignments. null when they hold no seat. |
team |
string | null |
That peer's team, from the same assignments. null when they are on no team. |
There is no EventContext here. The turn system caused this, not a person, so no actor is passed. Read the
peer from peerId instead.
How, why and when to use it
The start of a turn is where upkeep belongs: reset a per-turn move counter, untap everything, restart a clock,
announce whose go it is. onTurnStarted is the only event that fires at that exact moment for every way a turn
can begin — the first turn and every advance alike. The alternative is polling world.getTurn() from onTick
and noticing when activePeerId changes, which needs a tick handler running permanently, reacts up to a tenth of
a second late, and gives you neither the seat nor the team. Use world.getTurn() when you need the current
answer inside some other handler; use this delegate when the transition itself is the thing.
Example
// content/scripting-api/examples/globalevents.onTurnStarted.ts
// Scene script: run the start-of-turn upkeep. The payload is a plain record
// with no EventContext - the turn system caused this, not a player - so the
// peer id in it is the player whose turn began, not who clicked anything.
let turnNumber = 0;
globalEvents.onTurnStarted.add((turn) => {
turnNumber += 1;
const seat = turn.seat ?? "no seat";
const team = turn.team ?? "no team";
world.broadcast(`Turn ${turnNumber}: ${turn.peerId} (${seat}, ${team}).`);
const active = world.getPlayers().find((player) => player.peerId === turn.peerId);
world.log(active === undefined
? `Turn ${turnNumber} began for ${turn.peerId}, who is not in the roster yet.`
: `Turn ${turnNumber} began for ${active.displayName ?? active.peerId}.`);
void untapEverything();
});
async function untapEverything(): Promise<void> {
const cards = await world.getAllObjects({ kind: "card" });
for (const card of cards) {
if (card.faceUp === false) {
card.flip();
}
}
world.log(`Turned ${cards.length} cards face up for the new turn.`);
}
world.log("Turn upkeep is running.");
Each turn puts a line such as Turn 3: peer-8f2a (north, red). into the table chat, and the script console adds
Turn 3 began for Ada. followed by Turned 12 cards face up for the new turn.
Gotchas
This is not the mod hook onTurnStart. Mod scripting has its own hook of a very similar name, on a separate
surface, with a different payload — it also carries actionLimit, and it is delivered to a mod's api.on
callback, never to a delegate. These are not the same event. See
Mod hooks and capabilities.
Turn-start automation runs before your handler. When the table has "draw on turn start" enabled, the app
applies the draw to the first deck it finds before raising this event, so onCardDrawn and onObjectAction
for that draw have already fired by the time your upkeep starts.
The seat and team are a snapshot, not a subscription. They are read once, when the event is built. A player
who changes seat mid-turn raises onSeatChanged; nothing re-raises this event.
See also
globalEvents.onTurnEnded— the other half of the transition.world.getTurn— the current state, readable at any time.TurnInfo— what that call gives you back.globalEvents.onSeatChanged— the event that changes the seat this one reported.- Events and delegates — ordering and multiplicity.
globalevents.onTurnEnded#
readonly onTurnEnded: ScriptDelegate<[{ peerId: string }]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when the active player's turn finishes. The app raises it from the turn-advance path, once, for the player
whose turn is ending, and then raises onTurnStarted for the next one. It carries a single field: the peer id.
Parameters
The handler receives one argument, transcribed from ScriptDelegate<[{ peerId: string }]>:
| Field | Type | Notes |
|---|---|---|
peerId |
string |
The peer whose turn just ended. Read from the room's activePeerId, so it is always the player who actually held the turn. |
No seat, no team, no EventContext. Unlike onTurnStarted, this payload carries nothing but the id. Look
the rest up with world.getPlayers() while the player is still connected, or keep your own record.
How, why and when to use it
You want a per-player clock, or to clear the "already moved this turn" flag for the player who is finishing, or
to score what they did before the next player starts changing the table. onTurnEnded is the last moment that is
unambiguously theirs. The alternative is doing all of it in onTurnStarted for the next player, which is what
most authors write first — it works until you need to attribute something to the player who just finished, at
which point you have already lost their id. Use onTurnEnded for closing out a player's turn and
onTurnStarted for setting up the next one; both fire, back to back, for every advance.
Example
// content/scripting-api/examples/globalevents.onTurnEnded.ts
// Scene script: end-of-turn bookkeeping. The payload carries the peer id and
// nothing else - not the seat, not the team - so look anything else up from
// world.getPlayers() while the player is still connected.
const secondsPlayed = new Map<string, number>();
let turnStartedAt = Date.now();
globalEvents.onTurnStarted.add((turn) => {
turnStartedAt = Date.now();
world.log(`Clock started for ${turn.peerId}.`);
});
globalEvents.onTurnEnded.add((turn) => {
const elapsed = Math.round((Date.now() - turnStartedAt) / 1000);
const total = (secondsPlayed.get(turn.peerId) ?? 0) + elapsed;
secondsPlayed.set(turn.peerId, total);
const player = world.getPlayers().find((entry) => entry.peerId === turn.peerId);
const who = player?.displayName ?? turn.peerId;
world.broadcast(`${who} took ${elapsed}s (${total}s in total).`);
void world.setSavedData(JSON.stringify([...secondsPlayed]), "turn-clock");
});
world.log("Turn clock is running.");
Ending a turn puts Ada took 34s (91s in total). into the table chat and stores the running totals under the
saved-data key turn-clock, so they survive a save and reload.
Gotchas
It fires only on an advance. Switching turn order on raises onTurnStarted with no preceding
onTurnEnded, and switching it off raises neither — so a game that ends by disabling turn order never sees a
final onTurnEnded. Close out a game from your own end condition, not from this event.
Turn-start automation for the next player runs between the two events. With "draw on turn start" enabled, the draw for the incoming player is applied before either turn event is raised, so a handler here can already see the next player's card on the table.
The peer id is not always in the roster. world.getPlayers() reflects the connected peers the host last
pushed into the sandbox, and a player who disconnected mid-turn is not in it. Fall back to the raw peerId, as
the example does, rather than assuming the lookup succeeds.
See also
globalEvents.onTurnStarted— the other half of the transition.globalEvents.onPlayerLeft— why a peer id here may not resolve to a player.world.setSavedData— persisting a total across a save.world.getPlayers— resolving a peer id to a name.- Events and delegates — ordering and multiplicity.
globalevents.onPlayerJoined#
readonly onPlayerJoined: ScriptDelegate<[PlayerInfo]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a peer appears in the host's connected-peer list for the first time. The app watches that list and raises one event per newly present peer, so it covers a first join and a reconnection alike. It reports arrival at the room, not sitting down — a peer who joins as a spectator raises this with no seat.
Parameters
The handler receives one argument, transcribed from ScriptDelegate<[PlayerInfo]>:
| Field | Type | Notes |
|---|---|---|
peerId |
string |
The arriving peer's id. |
displayName |
string | null |
Their chosen name, or null when they have not set one. |
seat |
string | null |
Their seat, read from the room's assignments at the moment of the event. null for a peer who has not sat down — which is the usual case on arrival. |
team |
string | null |
Their team, but only when the sandbox's roster already knows the peer; on a first join it is null. |
isHost |
boolean |
true only when the roster already knows the peer and marks them as host; false on a first join. |
There is no EventContext. The room caused this, not a person.
How, why and when to use it
You want to greet an arriving player, or hand them a marker once they take a seat, or start the game when the
table fills. This is the only signal that somebody new is present. The alternative is polling
world.getPlayers() from onTick and diffing the array yourself, which needs a permanent tick handler and gets
you the same information a tenth of a second later. Use onPlayerJoined for the arrival; use onSeatChanged for
the moment they actually sit, because a script that spawns a piece per arrival will spawn one for every
spectator too.
Example
// content/scripting-api/examples/globalevents.onPlayerJoined.ts
// Scene script: greet an arriving player and give them a piece once they sit
// down. The payload is a PlayerInfo, filled from the host's roster - so team
// and isHost are only populated when the roster already knows the peer.
const greeted = new Set<string>();
globalEvents.onPlayerJoined.add((player) => {
if (greeted.has(player.peerId)) {
return;
}
greeted.add(player.peerId);
const who = player.displayName ?? player.peerId;
world.broadcast(`${who} joined the table.`);
world.log(`joined: ${player.peerId} seat=${player.seat ?? "none"} team=${player.team ?? "none"} host=${String(player.isHost)}`);
if (player.seat !== null) {
void giveMarker(player);
}
});
async function giveMarker(player: PlayerInfo): Promise<void> {
const marker = await world.spawnObject({
kind: "token",
name: `marker-${player.seat ?? "spare"}`,
position: [0, 1, 0]
});
world.log(marker === null
? "spawnObject refused the request: kind must be a non-empty string."
: `Marker ${marker.id} is on the table for ${player.displayName ?? player.peerId}.`);
}
world.log("Greeter is running.");
An arrival puts Ada joined the table. into the table chat and prints
joined: peer-8f2a seat=none team=none host=false to the script console.
Gotchas
The payload is assembled twice, and the two halves can disagree. The app builds the event with peerId,
displayName and seat; the sandbox then looks the peer up in the roster the host last pushed in and, if it
finds them, returns that roster entry instead — which is where team and isHost come from
(apps/web/src/scripting/sandbox/tableScriptSandbox.html, playerInfoFor). On a first join the roster has not
caught up, so you get the event's own three fields plus team: null and isHost: false. Treat team and
isHost as unreliable here and read them from world.getPlayers() when you need them.
A reconnection looks like a new arrival. The app compares against the previous peer list, so a player who
drops and comes back raises this again with the same peerId. Guard with a Set, as the example does, if your
handler must run once per person rather than once per connection.
The host itself does not raise a join. A table script starts on the host, after the host is already present.
See also
PlayerInfo— every field, and whatnullmeans in each.globalEvents.onPlayerLeft— the departure.globalEvents.onSeatChanged— the moment they sit down.world.getPlayers— the complete roster, includingteamandisHost.- Events and delegates — ordering and multiplicity.
globalevents.onPlayerLeft#
readonly onPlayerLeft: ScriptDelegate<[PlayerInfo]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a peer disappears from the host's connected-peer list. The app raises one event per peer that was present a moment ago and is not now, so it covers a clean exit and a dropped connection identically. Nothing distinguishes the two.
Parameters
The handler receives one argument, transcribed from ScriptDelegate<[PlayerInfo]>:
| Field | Type | Notes |
|---|---|---|
peerId |
string |
The departing peer's id. |
displayName |
string | null |
The name they had when they were last seen, remembered from the previous peer list. null when they never set one. |
seat |
string | null |
Not carried by this event. The app sends only peerId and displayName; the sandbox fills the rest from the roster if the peer is still in it, and otherwise passes null. |
team |
string | null |
Same — null unless the roster still holds the peer. |
isHost |
boolean |
Same — false unless the roster still holds the peer. |
There is no EventContext. The room caused this, not a person.
How, why and when to use it
A player leaves mid-game and their cards are still face-down in a seat nobody can reach. onPlayerLeft is where
you deal with that: return their pieces, pause the game, take them out of your own turn bookkeeping. The
alternative is checking world.getPlayers() at the start of every turn, which is what most scripts do first — it
works but leaves the table in a broken state for as long as the current turn lasts, and by then you no longer
know which seat was theirs. Use this delegate for cleanup at the moment of departure, and keep your own
peer-to-seat map so you still know where they were sitting.
Example
// content/scripting-api/examples/globalevents.onPlayerLeft.ts
// Scene script: hand a departed player's pieces back to the table. Read the
// seat from your own bookkeeping, not from the payload - by the time this
// fires the roster may already have dropped the peer, leaving seat as null.
const seatByPeer = new Map<string, string>();
globalEvents.onPlayerJoined.add((player) => {
if (player.seat !== null) {
seatByPeer.set(player.peerId, player.seat);
}
});
globalEvents.onSeatChanged.add((change) => {
if (change.seat === null) {
seatByPeer.delete(change.peerId);
} else {
seatByPeer.set(change.peerId, change.seat);
}
});
globalEvents.onPlayerLeft.add((player) => {
const seat = seatByPeer.get(player.peerId) ?? player.seat;
seatByPeer.delete(player.peerId);
const who = player.displayName ?? player.peerId;
world.broadcast(`${who} left${seat === null || seat === undefined ? "" : ` (seat ${seat})`}.`);
world.log(`left: ${player.peerId}; ${world.getPlayers().length} players remain.`);
});
world.log("Departure watcher is running.");
A departure puts Ada left (seat north). into the table chat and prints left: peer-8f2a; 2 players remain. to
the script console.
Gotchas
Do not trust seat in this payload. The event the app sends carries only peerId and displayName. The
sandbox tries the roster first and falls back to { seat: null, team: null, isHost: false }
(apps/web/src/scripting/sandbox/tableScriptSandbox.html, playerInfoFor), and the roster no longer holds a
peer that has disconnected. The peerId and displayName are always right; the other three fields are
best-effort. Keep your own map, as the example does.
A reconnection raises a leave and then a join. A player whose connection blips produces a full
departure/arrival pair with the same peerId. Anything destructive in this handler — returning cards, ending the
game — will run for a blip as well as for a real exit.
world.getPlayers() inside the handler already excludes them. The roster update that triggered the event has
already been applied, so the count you read here is the count after they left.
See also
PlayerInfo— every field, and whatnullmeans in each.globalEvents.onPlayerJoined— the arrival, and the same two-source payload.globalEvents.onSeatChanged— how to keep the seat map the example relies on.world.getPlayers— the roster as it stands after the departure.- Events and delegates — ordering and multiplicity.
globalevents.onSeatChanged#
readonly onSeatChanged: ScriptDelegate<[{ peerId: string; seat: string | null }]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a peer's seat assignment changes. The app diffs the room's assignment map after every update and raises one event per peer whose seat is different from what it was, so sitting down, standing up and moving from one seat to another all reach you here. Taking a seat and taking a team are separate events even when they happen in the same click.
Parameters
The handler receives one argument, transcribed from
ScriptDelegate<[{ peerId: string; seat: string | null }]>:
| Field | Type | Notes |
|---|---|---|
peerId |
string |
The peer whose seat changed. |
seat |
string | null |
The new seat. null means they stood up and now hold no seat. The previous seat is not carried — keep it yourself if you need it. |
There is no EventContext, and no previous value. Mod scripting's equivalent hook does carry a
previousSeat; this delegate does not.
How, why and when to use it
Your game cannot start until every seat is filled, and it has to stop if somebody stands up mid-game. This is the
event for both. The alternative most authors reach for is onPlayerJoined, which fires when a peer arrives — and
a peer arrives before they sit down, so a script that starts the game on arrival starts it too early and never
notices somebody leaving their seat without leaving the room. Use onPlayerJoined for "who is here" and
onSeatChanged for "who is playing".
Example
// content/scripting-api/examples/globalevents.onSeatChanged.ts
// Scene script: start the game once every seat is filled. The payload gives
// you the new seat and nothing else - no previous seat, no EventContext - so
// keep the mapping yourself if you need to know what changed.
const REQUIRED_SEATS = 2;
const seatByPeer = new Map<string, string>();
let started = false;
globalEvents.onSeatChanged.add((change) => {
const previous = seatByPeer.get(change.peerId) ?? "none";
if (change.seat === null) {
seatByPeer.delete(change.peerId);
world.log(`${change.peerId} stood up from ${previous}.`);
} else {
seatByPeer.set(change.peerId, change.seat);
world.log(`${change.peerId} moved ${previous} -> ${change.seat}.`);
}
if (!started && seatByPeer.size >= REQUIRED_SEATS) {
started = true;
world.broadcast(`All ${REQUIRED_SEATS} seats are taken - starting.`);
}
});
for (const player of world.getPlayers()) {
if (player.seat !== null) {
seatByPeer.set(player.peerId, player.seat);
}
}
world.log(`Seat watcher is running; ${seatByPeer.size} seats already taken.`);
Sitting down prints peer-8f2a moved none -> north.; the second player to sit also puts
All 2 seats are taken - starting. into the table chat.
Gotchas
Seed your map at startup. The script host starts after players are already seated, and no event replays the
existing assignments. The example's loop over world.getPlayers() is not optional — without it the first
onSeatChanged reports a previous seat of none for somebody who was sitting all along.
Standing up and sitting elsewhere is one event, not two. The diff is per peer and per field, so a move from
north to south raises exactly one call with seat: "south".
A seat change and a team change are two events. If a peer's seat and team both change in the same update,
onSeatChanged is raised first and onTeamChanged second, both for the same peer.
See also
globalEvents.onTeamChanged— the same diff, for the other field.globalEvents.onPlayerJoined— arrival, which is not the same as sitting down.PlayerInfo.seat— where the current value lives.world.getPlayers— how to seed the map at startup.- Events and delegates — ordering and multiplicity.
globalevents.onTeamChanged#
readonly onTeamChanged: ScriptDelegate<[{ peerId: string; team: string | null }]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a peer's team assignment changes. The app diffs the room's assignment map after every update, raising one event per peer whose team differs from its previous value — joining a team, leaving one, and switching from one to another all arrive here.
Parameters
The handler receives one argument, transcribed from
ScriptDelegate<[{ peerId: string; team: string | null }]>:
| Field | Type | Notes |
|---|---|---|
peerId |
string |
The peer whose team changed. |
team |
string | null |
The new team. null means they now belong to no team. The previous team is not carried. |
There is no EventContext, and no previous value.
How, why and when to use it
You are running a two-team game and the score, the reveal rules and the win condition all depend on who is on
which side. This delegate is where you keep that membership current. The alternative is reading
player.team out of world.getPlayers() whenever you need it, which is correct and often simpler — the reason
to subscribe is that team membership changing is itself a game event: sides have to be even before a round
starts, and a mid-game switch is something the table should be told about. Poll getPlayers() when you only
need the answer; subscribe when the change matters.
Example
// content/scripting-api/examples/globalevents.onTeamChanged.ts
// Scene script: keep per-team membership for a team game. This fires only
// when a peer's team assignment actually changes, so it will not fire at all
// on a table where nobody uses teams.
const teamByPeer = new Map<string, string>();
function teamSizes(): string {
const sizes = new Map<string, number>();
for (const team of teamByPeer.values()) {
sizes.set(team, (sizes.get(team) ?? 0) + 1);
}
return [...sizes].map(([team, count]) => `${team}:${count}`).join(" ") || "empty";
}
globalEvents.onTeamChanged.add((change) => {
const previous = teamByPeer.get(change.peerId) ?? "none";
if (change.team === null) {
teamByPeer.delete(change.peerId);
} else {
teamByPeer.set(change.peerId, change.team);
}
world.log(`${change.peerId} moved team ${previous} -> ${change.team ?? "none"}.`);
world.broadcast(`Teams are now ${teamSizes()}.`);
});
for (const player of world.getPlayers()) {
if (player.team !== null) {
teamByPeer.set(player.peerId, player.team);
}
}
world.log(`Team watcher is running; teams are ${teamSizes()}.`);
Joining a team prints peer-8f2a moved team none -> A. to the script console and puts Teams are now A:1. into
the table chat.
Gotchas
Seed your map at startup. Nothing replays existing assignments when the script host starts, so a script that only listens sees an empty table of teams until somebody changes one.
It never fires on a table that does not use teams. A game where nobody is assigned a team produces no calls at all — which means an initialization that lives only inside this handler will never run.
A seat change and a team change are two events. When both change in the same update,
onSeatChanged fires first and this one second, for the
same peer.
Team drives hidden-information reveals, and a script cannot request one. The three reveal-* actions belong
to the host. A script models a team-visible state with its own tags or saved data, and lets the host's reveal
path stay the only thing that flips card visibility.
See also
globalEvents.onSeatChanged— the same diff, for the other field.PlayerInfo.team— where the current value lives.world.getPlayers— how to seed the map at startup.- Action vocabularies — why
reveal-team-aandreveal-team-bare not scriptable. - Events and delegates — ordering and multiplicity.
globalevents.onChatMessage#
readonly onChatMessage: ScriptDelegate<[{ peerId: string; displayName: string | null; text: string }]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires for every chat line the host processes. The app raises it from two places: when a message arrives from a peer, and when the person at the host machine sends one. A muted peer's message is dropped before the event, and a host command is handled before it, so neither reaches your handler.
Parameters
The handler receives one argument, transcribed from
ScriptDelegate<[{ peerId: string; displayName: string | null; text: string }]>:
| Field | Type | Notes |
|---|---|---|
peerId |
string |
The sender's peer id. For a message typed on the host machine this is the host's own peer id, or the literal "host" when the room has not assigned one yet. |
displayName |
string | null |
The name as it is shown in the chat panel, already resolved by the app: the sender's chosen name, the literal "You" for the host's own message, or the first eight characters of the peer id when no name is known. |
text |
string |
The message, exactly as typed. Not trimmed, not lower-cased, never null. |
There is no EventContext. The peerId field is the actor.
How, why and when to use it
A table script has no interface of its own — no buttons, no panels, no menu entries. Chat is the only channel a
player has for asking a script to do something, which makes this delegate the standard way to build a command:
!roll, !score, !reset. The alternative is a mod, which can render buttons through api.setUiElement and
dispatch them to api.on — that is a different surface with a different language and a publishing step, so it is
the right answer for a finished game and the wrong one for a rule you are adding to a scene. Use chat commands
inside a table script; move to mod UI when the interaction deserves a control.
Example
// content/scripting-api/examples/globalevents.onChatMessage.ts
// Scene script: a chat command. Chat is the only channel a player has to ask
// a script for something, because a table script has no UI of its own.
globalEvents.onChatMessage.add((message) => {
const text = message.text.trim();
const who = message.displayName ?? message.peerId;
if (text === "!count") {
void reportCount(who);
return;
}
if (text === "!roll") {
void rollEverything(who);
return;
}
world.log(`chat from ${who} (${message.peerId}): ${text}`);
});
async function reportCount(who: string): Promise<void> {
const entities = await world.getAllObjects();
world.broadcast(`${who}: there are ${entities.length} entities on the table.`);
}
async function rollEverything(who: string): Promise<void> {
const dice = await world.getAllObjects({ kind: "die" });
for (const die of dice) {
die.roll();
}
world.broadcast(`${who} rolled ${dice.length} dice.`);
}
world.log("Chat commands !count and !roll are registered.");
Typing !count in the table chat replies You: there are 14 entities on the table.; anything else is logged to
the script console as chat from Ada (peer-8f2a): good luck.
Gotchas
displayName is a display string, not an identity. For the host's own message it is the literal "You",
which is what the chat panel shows and is useless for telling players apart. Key anything that matters on
peerId.
world.broadcast does not re-enter this handler. A broadcast posts the line to every peer as coming from the
table; it does not raise onChatMessage, so a script replying to a command cannot trigger itself.
Muted peers and host commands never arrive. The app drops a message from a muted peer and consumes a recognized host command before raising the event, so your handler sees neither.
Match on trimmed text. The text field is the raw string; a player who types !roll with a trailing space
misses an equality check that does not trim.
See also
world.broadcast— replying to the whole table.world.log— writing where only the host can read it.EventContext— the actor object the entity events carry, which this one does not.- Choosing a surface — when the interaction is worth a mod's UI instead.
- Events and delegates — ordering and multiplicity.
globalevents.onTick#
readonly onTick: ScriptDelegate<[number]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Throttled host tick (~10 Hz). Registering a handler enables delivery.
A throttled heartbeat from the host, delivered roughly ten times a second. It is the only delegate here that nothing at the table triggers — a timer on the host drives it. It is also the only one that is opt-in: registering the first handler tells the host to start sending ticks, and removing the last one tells it to stop, so a table with no tick handler pays nothing at all.
Parameters
The handler receives one argument, transcribed from ScriptDelegate<[number]>:
| Position | Type | Notes |
|---|---|---|
| 1 | number |
Seconds since the previous tick, measured — the elapsed milliseconds divided by 1000, clamped at zero (apps/web/src/ui/App.tsx and apps/web/src/ui/TableEditModeShell.tsx, the tick drivers). Close to 0.1 in the ordinary case, larger after a late tick. The sandbox passes a non-numeric value through as 0. |
There is no EventContext. Nobody caused this.
How, why and when to use it
You want a turn timer that warns the table at ten seconds and ends the turn at zero. Nothing else in the API
gives you a repeating callback: world.wait resolves once, and there is no interval primitive in the sandbox.
The alternative for anything that has to line up with a specific moment is the event for that moment —
onObjectDropped for a move, onTurnStarted for a turn — and those are always better, because they are exact
and cost nothing when nothing happens. Use onTick only for genuinely time-based work: a countdown, a periodic
tidy-up, a slow animation. Remove the handler when the work is done, as the example does, and delivery switches
off with it.
Example
// content/scripting-api/examples/globalevents.onTick.ts
// Scene script: a turn timer. Ticks arrive about ten times a second and only
// while a handler is registered, so this removes its own handler when the
// countdown ends and the host stops sending them.
const TURN_SECONDS = 30;
let remaining = TURN_SECONDS;
let lastAnnounced = TURN_SECONDS;
function countdown(dt: number): void {
// dt is the MEASURED gap since the previous tick, not the nominal 0.1, so a
// late tick subtracts the time that really passed.
remaining -= dt;
const whole = Math.ceil(remaining);
if (whole < lastAnnounced && whole % 10 === 0 && whole > 0) {
lastAnnounced = whole;
world.broadcast(`${whole}s left in this turn.`);
}
if (remaining <= 0) {
world.broadcast("Time is up.");
globalEvents.onTick.remove(countdown);
world.log("Countdown finished; tick delivery switched off.");
}
}
globalEvents.onTurnStarted.add((turn) => {
remaining = TURN_SECONDS;
lastAnnounced = TURN_SECONDS;
world.log(`Countdown restarted for ${turn.peerId}.`);
});
globalEvents.onTick.add(countdown);
world.log(`Turn timer armed for ${TURN_SECONDS}s.`);
The table chat shows 20s left in this turn. and 10s left in this turn., then Time is up., after which the
script console prints Countdown finished; tick delivery switched off.
Gotchas
By design. Ticks arrive at roughly 10 Hz, not once per frame. Every tick crosses a
postMessageboundary into the sandbox, and a per-frame event on that path would cost more than any gameplay it enables. Delivery is gated on interest — the sandbox tells the host the moment the first handler is added and the moment the last one is removed (apps/web/src/scripting/TableScriptHost.ts,wantsTick) — so this is not expected to change. Drive anything that has to line up with a specific moment from the event for that moment instead.
dt is measured, so integrate it rather than counting ticks. Both drivers time the gap between deliveries,
so a backgrounded or busy tab produces one large dt rather than a run of missing 0.1s and a total that
integrates dt stays true to the wall clock. What dt cannot tell you is that ticks were skipped — a two
second gap arrives as one call with dt near 2, not twenty calls. Never treat a tick as a fixed unit of time.
Removing the last handler is what switches ticks off. Adding a second handler and removing one leaves delivery on. The count is per sandbox, across every script, not per script.
Ticks are not delivered before started. Delivery begins after the frame has finished running the script
bodies, so nothing arrives during boot.
See also
ScriptDelegate.remove— why the handler must be a named function.world.wait— the one-shot alternative, for pacing rather than polling.globalEvents.onTurnStarted— the exact moment a countdown should restart.onTickis throttled and opt-in — the reasoning, in full.- Events and delegates — ordering and multiplicity.
