Dicey Table

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 own try/catch if 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 postMessage boundary 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. Use onTick for 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. onObjectAction fires when the roll impulse is applied; onDiceRolled fires 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. onDiceRolled also fires for a die that tumbled to rest with no roll action 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. A value of null means 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 onDiceRollResult once for the whole throw — after every onDiceRolled in 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. onObjectCreated fires for a spawn intent 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 from world.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#