Events and delegates
A delegate is a multicast subscription point. You add a handler with .add(fn) and remove
it with .remove(fn). There is no return value to inspect, no event object to mutate, and no
way to stop what triggered it.
Table scripting has 29 subscription points: 21 table-wide delegates on globalEvents,
and 8 entity-scoped delegates that live on every ObjectHandle — including refObject.
Mod scripting has a separate system with separate names. api.on("onTurnStart", fn) is not
globalEvents.onTurnStarted.add(fn); they belong to different surfaces and neither one is
layered on the other. See Mod hooks and capabilities.
Scope: table-wide or one entity#
globalEvents.onObjectDropped |
refObject.onDropped |
|
|---|---|---|
| Fires for | every entity on the table | only the handle's own entity |
| Available in | scene scripts and object scripts | object scripts (through refObject), or any handle you hold |
| Handler receives | the entity's handle, plus the context | the same handle, plus the context |
The eight entity-scoped delegates are onCreated, onDestroyed, onPickedUp, onDropped,
onAction, onRolled, onCardDrawn and onShuffled. Every one of them has a table-wide twin
on globalEvents under a longer name — onRolled pairs with onDiceRolled, onShuffled with
onContainerShuffled. The remaining thirteen globalEvents delegates are about the table rather
than one entity — a finished dice roll, the four zone and trigger crossings, and the eight room
events (turns, players, seats, teams, chat, ticks) — and have no entity-scoped form.
onCardDrawn is the one whose scope is not the event's entity. A cardDrawn event names the
newly created card, and routing it to that card's handle could never reach a subscriber — the
handle is born during the same fan-out. The sandbox therefore routes it to the container, by
context.containerId, so deck.onCardDrawn fires for every card drawn off that deck while the
handle argument is still the card.
Which peer fires an event#
All of them fire on the host, and only on the host. The runtime raises lifecycle events on every
peer, and the app hands them to the script host only where the table is authoritative — the room
host, or a solo table. A player or spectator peer raises the same events internally and
throws them away, because it has no scripts to give them to. See
Host authority.
Ordering, exactly#
Three orderings matter, and all three are fixed.
1. Handlers fire in registration order. The first handler added to a delegate is the first
one called, whichever script added it. .remove(fn) removes the first entry whose handler is
the identical reference — so you must keep a reference to a handler you intend to remove; an
inline arrow function cannot be removed.
2. A handler that throws does not stop the others. The sandbox iterates a copy of the
handler list and catches each call individually, reports the failure as a handler-phase
diagnostic attributed to the script that registered it, and continues with the next handler.
Adding or removing a handler from inside a handler is safe for the same reason: the fan-out in
progress runs against the copy it started with, and your change takes effect on the next event.
3. Table-wide delegates fire before entity-scoped ones. For each event, globalEvents fans
out first, then the matching delegate on the event's entity handle. A script that keeps a
running total in a globalEvents handler and reads it in an refObject handler sees the
updated total.
How many times each event fires#
Every object action raises onObjectAction in addition to its semantic event. One roll
gesture is two handler calls, not one. The order within a single action is fixed:
| Action | Delegates raised, in order |
|---|---|
roll |
onObjectAction at the throw, then onDiceRolled when the die stops — about a second later, not back-to-back |
shuffle |
onObjectAction, then onContainerShuffled |
delete |
onObjectAction, then onObjectDestroyed (reason: "deleted") |
draw |
onObjectCreated for the new card, onCardDrawn, then onObjectAction — and when the draw empties or exhausts the deck, onObjectDestroyed ("depleted" / "converted") fires between the last two |
split |
onObjectCreated for the pile that moved off, then onObjectAction. When the remainder is a single card, that card raises its own onObjectCreated and the emptied deck raises onObjectDestroyed ("converted"), both before onObjectAction |
combine |
one onObjectDestroyed per absorbed entity (reason: "absorbed", containerId naming the survivor), then onObjectCreated if a new stack was made, then onObjectAction. Merging into an existing deck creates nothing, so it is destroys-then-onObjectAction. The survivor is never destroyed |
flip rotate lock unlock tap untap |
onObjectAction only |
Applies to: every object kind. The action reaches the runtime whatever the entity is; what differs is whether the runtime does anything with it, which Action vocabularies sets out per kind.
Pickups and drops fire once per entity, not once per gesture. Dragging a multi-entity
selection raises onObjectPickedUp for every member of the selection and onObjectDropped for
every member that still exists when the drag ends — a combine or shuffle during the drag can
consume one. A remote player's drag raises exactly one onObjectPickedUp when the drag starts
and one onObjectDropped on release, attributed to that player's peer id.
onObjectDestroyed is the one event whose two scopes carry different arguments: the table-wide
delegate receives the destroyed entity's id and the context, while the entity-scoped
onDestroyed receives only the context — the handle is the subject, so there is nothing to pass
it. Immediately after the entity-scoped fan-out, the sandbox discards the handle. Anything you
still need from that entity must be read before it is destroyed.
It is also the one event with four causes, and
context.reason is what tells them apart:
"deleted", "depleted", "converted" and "absorbed". Only the first is somebody asking for the
entity to go. "absorbed" in particular is not a loss — the entity's cards live on inside
context.containerId, and a card drawn back out of that pile is a new entity with a new id.
Entity ids are deliberately not preserved across a merge: a preset deck's cards were never entities
at all, so no such contract could hold uniformly. Reconnect on card identity — the cardId in
the deck's metadata.cards entries, and the label the drawn card is created with — never on the
entity id you saw destroyed. See
ObjectDestroyedReason.
Nothing can cancel an action#
There is no veto hook, no try* delegate, no preventDefault, and no return value a handler
can use to refuse what triggered it. By the time a handler runs, the host has already applied
the change and broadcast it. This is a deliberate deferral — the design note records that veto
hooks "need a sync-over-async design decision" and are held for a follow-up — and it is the
single biggest difference between this event model and the one Tabletop Simulator authors are
used to.
What you do instead: react and correct. Let the action happen, then push the table back into
a legal state from the handler — move the piece back with setPosition, flip() it again,
destroy() what should not be there. Or gate the interaction before it can start, using the
host's own controls (lock the entity, put it in an owned zone, turn on turn order) rather than
script logic.
Async handlers#
A delegate handler is declared as returning void, and the sandbox ignores whatever it returns.
An async handler still runs — its synchronous part executes during the fan-out and the rest
continues later — but nothing awaits it. Two consequences:
- Handlers do not run one-after-another once they hit an
await. Two async handlers on the same delegate interleave. - A rejected promise inside an async handler is not caught by the sandbox's per-handler
try, so it never becomes a diagnostic. Wrap the body in your owntry/catchif you want to see the failure in the script console.
onTick#
onTick is opt-in and throttled. Registering the first handler tells the host to start sending
ticks; removing the last one tells it to stop, so a table with no tick handler pays nothing.
By design. Ticks are delivered at roughly 10 Hz, not per frame. Every tick crosses a
postMessageboundary into the sandbox, and a per-frame event on that path would cost more than any gameplay it enables. The throttle is not expected to change. UseonTickfor coarse-grained polling — a countdown, a periodic tidy-up — and drive anything that has to line up with a specific moment from the event for that moment instead.
The dt a handler receives is measured — the milliseconds since the previous tick, divided
by 1000 (apps/web/src/ui/App.tsx and apps/web/src/ui/TableEditModeShell.tsx, the tick
drivers). It is close to 0.1 in the ordinary case and larger whenever the tab was backgrounded
or the host was busy and a tick arrived late, so integrating it against the wall clock is safe.
What it does not do is tell you a tick was skipped: a long gap arrives as one large dt, not
as several ticks.
Where a payload and its declaration disagree#
Both action delegates are declared with ObservedObjectAction, which is the wider of the two
action types: the 13 names a script may request plus the five the engine raises and no script can
ask for (lift, flick, reveal-all, reveal-team-a, reveal-team-b). The declaration and
the payload agree. Still write a default branch — the two lists are maintained by hand in
different files, and a handler with no default goes silently wrong the day one of them grows.
See ObservedObjectAction.
Watch the gap between the two.
onObjectActionfires when therollimpulse is applied;onDiceRolledfires when the die comes to REST, roughly a second later, carrying the number printed on the face it settled on (apps/web/src/playcanvas/TabletopRuntime.ts,settleDieFaceValue). They are the only pair in the table above that is not effectively simultaneous.onDiceRolledalso fires for a die that tumbled to rest with norollaction behind it — a shake-throw, or a die knocked hard enough to spin — attributed to"Host"; a die merely nudged across the table does not raise it. Avalueofnullmeans no readable number: the die is cocked, or it is a custom model with no face table.A batch roll adds a third event. Dice rolled from the table's dice picker settle as a group, and when the last one stops the host raises
onDiceRollResultonce for the whole throw — after everyonDiceRolledin that batch — carrying the total, the notation and every face. Score at one level or the other; adding to a running total in both handlers counts the roll twice.
By design.
onObjectCreatedfires for aspawnintent and for every gameplay path that produces an entity — a card drawn off a deck, a card dealt to a seat, either half of a split deck, both combine paths, and the card a one-card deck turns into (apps/web/src/playcanvas/TabletopRuntime.ts,createGameplayObject). It does not fire when a table is rebuilt from a snapshot, because every entity is recreated through the same low-level function and announcing there would report "created" for the whole table on every snapshot apply. Read the event as "this has just come into existence", not "this is now present": seed any index fromworld.getAllObjects()at start and keep it current with the event. See Known limitations.
Example#
// content/scripting-api/examples/concepts.events-and-delegates.ts
// Scene script: two handlers on one delegate, plus a handler that unsubscribes
// itself. Handlers run in the order they were added, on the host peer only.
let dropCount = 0;
function countDrops(entity: ObjectHandle, context: EventContext): void {
dropCount += 1;
world.log(`1. counted drop ${dropCount}: ${entity.name ?? entity.kind} by ${context.actor}`);
}
function announceFirstDrop(entity: ObjectHandle, context: EventContext): void {
world.log(`2. first drop of the game: ${entity.name ?? entity.kind} by ${context.actor}`);
// One-shot: remove by the same reference that was added. An inline arrow
// cannot be removed, because you have nothing to pass to remove().
globalEvents.onObjectDropped.remove(announceFirstDrop);
}
globalEvents.onObjectDropped.add(countDrops);
globalEvents.onObjectDropped.add(announceFirstDrop);
// Every action also raises onObjectAction, so this fires alongside the
// semantic delegate for the same gesture.
globalEvents.onObjectAction.add((entity, action, context) => {
world.log(`action ${action} on ${entity.id} by ${context.actor}`);
});
world.log("Drop handlers registered.");
On start the script console prints Drop handlers registered. The first time anyone drops an
entity it prints two lines in registration order — 1. counted drop 1: red-die by You then
2. first drop of the game: red-die by You. Every drop after that prints only the numbered
counter line, because the second handler removed itself.
See also#
- Execution order — when handlers get registered in the first place.
- Host authority — why every event fires on one peer, and what
actorreally contains. - Async and snapshots — reading the table from inside a handler.
globalEvents— every delegate, its payload and its badges.- Known limitations — the full list of documented gaps.
- Mod scripting API —
api.on, the mod-side registry, with its own event names. ObjectHandle— the per-entity delegates a handle carries.
