Dicey Table

Action vocabularies

An object action is a named, host-applied change to one entity on the table: flip it, roll it, shuffle it, delete it. Every one of them travels as an object-action intent, and the host is the only peer that applies it.

There are three lists of action names, and they are not the same list:

Vocabulary Size Who uses it
Engine 23 The runtime itself. Everything the host knows how to do.
Table script 13 What a table script may request. Enforced by the script host.
Mod 10 What a mod may request. Enforced twice — in the sandbox frame and again on the host.

One of the engine's 19 — press — is not in either script vocabulary and is not a subset boundary the way the rest are: a player presses a button, the host raises the action, and a table script can only observe it. It is called out where the values are listed below rather than folded into the subset story.

Each list is a subset of the one above it. The narrowing is deliberate in some places and accidental in others, and this page says which is which — because a reader who cannot tell the difference reasonably concludes the whole thing is arbitrary.


The engine vocabulary — 23 actions#

Every action the host can apply. This is the full set; the other two vocabularies are drawn from it.

The Players may send column is the per-kind allowlist that gates an action arriving from a player or spectator — checked on that client before it sends, and again on the host when it arrives. It is not applied to actions raised on the host itself: the host's own UI, a table script, and a mod running on the host all reach the runtime directly. So a table script can request flip on a die, and the host will apply the flip even though no player could have asked for it.

Two rules sit above the whole table: a locked entity refuses every action except unlock and press, and an action against an entity that does not exist does nothing.

Action What the host does Players may send it for
lift Teleports the entity 0.75 ft straight up. every kind
flip Toggles faceDown and rotates the entity 180° about its local X axis. On a deck it also flips every card entry and re-derives the visible face. card, deck
rotate Rotates the entity 90° about its local Y axis. every kind
lock Marks the entity locked and switches its rigidbody to static. every kind, but only while unlocked
unlock Clears the lock and restores the authored body type. every kind, but only while locked
tap Sets tapped to true. no kind — see below
untap Sets tapped to false. no kind — see below
shuffle Shuffles the deck's contents and starts the cosmetic shuffle spin. Refused outright for every other kind — see below. deck
draw Draws the first item off the container. deck, bag
deal Deals from the container to the seated players. deck, bag
split Splits a stacked deck into two. deck, and only while its stack holds more than one card
combine Merges the entity into a stack. card, deck
roll Throws the die with a randomized impulse and lets physics settle it. die
press Dips a button's cap, plays its sound and raises onPressed. Refused outright on any other kind. Exempt from the locked rule. button
flick Flicks the entity across the surface. every kind except board
reveal-all Clears the reveal team and, unless the entity is owned by a seat, turns it face up. card, deck
reveal-team-a Reveals the entity to team A only. card, deck
reveal-team-b Reveals the entity to team B only. card, deck
peek Privately reveals the top N cards of a container to the peeking player only (host-authoritative), and logs one public, identity-free line naming the count and the deck. Carries a count; every other action ignores it. deck, bag
search Opens a private search session: reveals the container's whole contents to the searching player only, and logs one public, identity-free line naming the count and the pile. Gated by the authored audience, and refused silently when it does not include the actor. deck, bag
search-pull Takes one named item out of a pile the actor is currently searching, through the ordinary draw path. Carries a cardId; the host refuses one the actor's live reveal does not cover. The public line names the pile, never the card. deck, bag
search-close Ends the session: applies the pile's authored order policy (shuffle the remainder, or keep it) and revokes the reveal. deck, bag
delete Destroys the entity, re-parents its children to its own ancestor, and removes its joints. every kind

What happens when a script sends an action to the wrong kind. Because a script bypasses the participant gate, it can address any action to any entity. Four of the container actions guard themselves anyway and return without doing anything: draw and deal do nothing unless the target is a deck or a bag, and split does nothing unless it is a deck with more than one card. The rest do not self-guard — roll on a card throws the card, flip on a die turns it over and flips its faceDown flag. Check handle.kind before acting on an entity you did not spawn yourself.

shuffle and press refuse rather than no-op. applyObjectAction returns immediately for a shuffle whose target is not a deck, and for a press whose target is not a button: no state change, no log line, no snapshot, no sound, no spin. Those refusals are on the host side of the gate, so they apply to a table script and a host-side mod too, not only to a player's intent. handle.shuffle() on a bag therefore does nothing at all and reports nothing — check handle.kind === "deck" first if the distinction matters. (There is no press method on ObjectHandle; a script cannot request press at all — it only observes it.)

By design — shuffle is deck-only. A bag used to be on the allowlist, and used to spin 45° when you asked. It never reordered anything: a bag draws at random (containerDrawMode returns "random" for bag), so its contents have no order for a shuffle to change, and the 45° yaw was the only observable effect. The allowlist now says what the action does, so a bag no longer offers Shuffle in the right-click menu or the VR object menu, and shaking one no longer dispatches it. draw and deal are unchanged for bags — those are what a bag is for. If your game shuffled a bag for the visual, replace it with handle.rotate().

Known gap. tap and untap have no case in the per-kind allowlist, so it refuses them for every kind. A player or spectator can never send either one; only the host — including a table script — can. The runtime applies them correctly when they arrive, so the behavior is complete; only the participant gate is missing them. See Known limitations.


The table-script vocabulary — 13 actions#

ObjectAction in the table-scripting type declarations. The script host re-checks the same 13 names before it passes an object-action intent to the runtime, so a compromised sandbox frame cannot broaden the set.

flip · rotate · lock · unlock · tap · untap · shuffle · draw · deal · split · combine · roll · delete

That is the engine's 23 minus lift, flick, press, peek, search, search-pull, search-close, reveal-all, reveal-team-a and reveal-team-b.

Nine of the thirteen have an ObjectHandle method. The others are in the type union and would pass the script host's check, but the typed API has no way to call them:

Action How a script calls it
flip handle.flip()
rotate handle.rotate()
lock handle.lock()
unlock handle.unlock()
shuffle handle.shuffle()
draw handle.draw()
deal handle.deal()
roll handle.roll()
delete handle.destroy() — the method name differs from the action name
tap no method
untap no method
split no method
combine no method

Known gap. tap, untap, split and combine are declared in ObjectAction and are on the script host's allowlist, but ObjectHandle exposes no method for any of them, so a script cannot request them through the typed API. Nothing about them is disabled — the allowlist and the runtime both handle them — and only the calling surface is missing. See Known limitations.

Observing is a different, wider type. globalEvents.onObjectAction and ObjectHandle.onAction are declared with ObservedObjectAction — this union plus lift, flick, press, peek, the three search* actions and the three reveal-* actions, i.e. all 23. A script may watch every action the engine raises and request only these 13, and the two type names keep that distinction visible in the editor rather than leaving it to be discovered at run time. press is the clearest case: there is no way to request it, but a button script hears every press through onPressed.


The mod vocabulary — 10 actions#

SANDBOX_SAFE_OBJECT_ACTIONS, reached through api.objectAction(objectId, action) behind the object-action capability. One list, checked at three points: inside the sandbox frame, which throws on anything else; in SandboxedModRunner when the message reaches the host; and again in the host's objectAction implementation before the intent is dispatched. All three test the same ten names — the innermost gate is deliberately no laxer than the outer ones.

flip · rotate · lock · unlock · shuffle · draw · deal · split · combine · roll

That is the table script's 13 minus tap, untap and delete. A mod cannot delete an entity.

Unlike a table script, a mod passes the action name as a string, so split and combine are genuinely reachable here even though they have no table-script method.


Compatibility matrix#

Rows are the 23 engine actions. ✓ means present, ✗ means absent. press is the row that breaks the "each list is a subset of the one above" story: a player can send it, but neither script surface can request it — a table script only observes it (via ObservedObjectAction / ButtonObject.onPressed).

Engine action Engine Table script ObjectHandle method Mod allowlist
lift
flip flip()
rotate rotate()
lock lock()
unlock unlock()
tap
untap
shuffle shuffle()
draw draw()
deal deal()
split
combine
roll roll()
press ✗ (observe only)
flick
reveal-all
reveal-team-a
reveal-team-b
peek ✗ (observe only)
search ✗ (observe only)
search-pull ✗ (observe only)
search-close ✗ (observe only)
delete destroy()

Totals: 23 · 13 · 9 · 10.

The Table script column is the ObjectAction type union, which the script host's allowlist matches exactly. The ObjectHandle method column is what the typed in-frame API can actually call — the practical limit for a table script.


Why these differ#

Three of the four gaps are decisions. Two are unfinished work. Told apart:

Engine → table script: a deliberate boundary#

By design. lift, flick, press, peek, search, search-pull, search-close, reveal-all, reveal-team-a and reveal-team-b are engine-internal. lift and flick are drag mechanics — the physical result of a pointer gesture, meaningless without the gesture that produced it. press is the same in spirit: it is the result of a player clicking a button, so a script reacts to it (ButtonObject.onPressed) but has no reason to synthesize one — to make a button do something on its own, run that logic in onPressed rather than trying to fake the press. peek is a hidden-information reveal that only ever un-hides a container's top cards to the peeking player and only on the host, so — like the reveals below — a script may observe it but not synthesize one. The three search* actions are the same reveal widened to a whole pile and held open as a session, and they are withheld for the same reason twice over: a script that could open one would manufacture a private reveal for itself, and the event it observes deliberately never names the card that was pulled. See Deck and Bag Search. The three reveal-* actions are hidden-information reveals, and hidden information is the one part of the table the host must own outright: an action that can flip who sees a card is an action that can be used to cheat. Scripts do not get them, and that is not expected to change. Model a reveal as your own state — a tag, or saved data — and let the host's reveal path stay the only thing that changes card visibility.

Table script → mod: also deliberate, for a different reason#

By design. A mod is untrusted code fetched from a GitHub repository and gated by a capability allowlist; a table script is authored in the editor by whoever built the scene. They get different budgets. delete is withheld because a mod that can destroy entities can quietly dismantle a table it did not create, and tap/untap are withheld because no participant path sends them either. Build a mod around api.createObject and the actions it does have; a game that genuinely needs to remove pieces belongs in a table script.

ObjectActionObjectHandle: drift#

Known gap. tap, untap, split and combine are in the table-script type union and on the script host's allowlist, but ObjectHandle has no method for them. This is an oversight in the typed surface, not a restriction: every layer below is ready for them, and only the calling surface is missing. delete looks like the same problem and is not — it is reachable, as destroy(). See Known limitations.

The participant gate and tap/untap: drift#

Known gap. The per-kind allowlist that gates player and spectator actions has no case for tap or untap, so it refuses them for every kind. The actions work; only players cannot ask for them. See Known limitations.


Where these lists live#

If you are checking this page against the code, these are the definitions — by file and exported symbol, so the references survive edits.

List File Symbol
Engine, 19 packages/shared/src/tableObjects/intents.ts ObjectActionIntent["action"], aliased as TableObjectAction
Per-kind participant gate packages/shared/src/tableObjects.ts isObjectActionAllowedForTarget
Table script, 13 (types) packages/shared/src/scripting.ts the ObjectAction union inside DICEYTABLE_SCRIPT_API_DTS
Table script, 13 (enforcement) apps/web/src/scripting/TableScriptHost.ts SCRIPT_SAFE_OBJECT_ACTIONS, checked by validateScriptIntent
ObjectHandle methods packages/shared/src/scripting.ts the ObjectHandle interface inside DICEYTABLE_SCRIPT_API_DTS
Observed, 19 packages/shared/src/scripting.ts the ObservedObjectAction union inside DICEYTABLE_SCRIPT_API_DTS
Mod, 10 (in-frame) apps/web/src/mods/sandbox/modSandbox.html SAFE_OBJECT_ACTIONS
Mod, 10 (host, message) apps/web/src/mods/SandboxedModRunner.ts SANDBOX_SAFE_OBJECT_ACTIONS, checked by isSandboxSafeObjectAction
Mod, 10 (host, dispatch) apps/web/src/ui/App.tsx the mod runner's objectAction callback, checked by the same isSandboxSafeObjectAction
What each action does apps/web/src/playcanvas/TabletopRuntime.ts applyObjectAction

See also#