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 —
shuffleis deck-only. Abagused to be on the allowlist, and used to spin 45° when you asked. It never reordered anything: a bag draws at random (containerDrawModereturns"random"forbag), 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.drawanddealare unchanged for bags — those are what a bag is for. If your game shuffled a bag for the visual, replace it withhandle.rotate().
Known gap.
tapanduntaphave 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,splitandcombineare declared inObjectActionand are on the script host's allowlist, butObjectHandleexposes 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-aandreveal-team-bare engine-internal.liftandflickare drag mechanics — the physical result of a pointer gesture, meaningless without the gesture that produced it.pressis 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 inonPressedrather than trying to fake the press.peekis 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 threesearch*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 threereveal-*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.
deleteis withheld because a mod that can destroy entities can quietly dismantle a table it did not create, andtap/untapare withheld because no participant path sends them either. Build a mod aroundapi.createObjectand the actions it does have; a game that genuinely needs to remove pieces belongs in a table script.
ObjectAction → ObjectHandle: drift#
Known gap.
tap,untap,splitandcombineare in the table-script type union and on the script host's allowlist, butObjectHandlehas 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.deletelooks like the same problem and is not — it is reachable, asdestroy(). 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
taporuntap, 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#
ObjectHandle— the table-script methods in this page's third column.- Host authority — why only the host applies an action.
- Known limitations — every gap named on this page, in one list.
