Dicey Table

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 ScriptDelegateadd(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.
  • TypesPlayerInfo, TurnInfo, ObjectAction and 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 add calls happen relative to the table loading.
  • Mod hooks and capabilities — the other surface's 14 hooks, which are not these.
  • Sidecars — the triggers key that authors the volumes onTriggerEnter reports 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#

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

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

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#

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#

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

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

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

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

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#

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

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

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

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

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

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#

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 ignoredadd(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#

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

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#

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 createObject that 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 from world.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

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

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 marked grabbableWhileParented in its metadata is exempt unless the assembly is welded or an ancestor is restricted. Compare handle.id against 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

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. Compare handle.id against 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

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

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#

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

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

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#

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

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

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

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#

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

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

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#

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

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 postMessage boundary 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