Dicey Table

Object actions

TableObjectAction is the 23-value action union (ObjectActionIntent["action"] in packages/shared/src/tableObjects/intents.ts) — everything the runtime can do to a single entity in response to an object-action intent. What each action does and how it is gated across the three action vocabularies (engine, table script, mod) lives on Action vocabularies — that page owns the surface-vs-surface axis. This page owns a different axis: which entity kinds each action is valid for, from isObjectActionAllowedForTarget() (packages/shared/src/tableObjects.ts).

What this gate is for, and what it is not#

isObjectActionAllowedForTarget(action, target) is the participant gate: it is checked on a player or spectator's own client before an object-action intent is sent, and again by the host when the intent arrives, for intents that originate from a participant. It is not applied to actions the host raises directly — the host's own UI, a table script, and a mod running host-side all reach the runtime without going through this function, so (for example) a table script can request flip on a die and the runtime will apply it even though a player could never have sent that combination. See Action vocabularies for what happens when an action reaches a kind it was not designed for.

Two rules sit above the whole matrix, checked before any kind-specific case:

  • A locked entity refuses every action except unlock and press. The matrix below assumes the target is unlocked, except in the unlock row. press is exempt because a button ships locked on purpose (so a stray drag cannot move it) and must stay pressable.
  • A missing target is never allowed. isObjectActionAllowedForTarget(action, null) is always false.

The 9 kind × 23 action matrix#

✓ = allowed for that kind (target unlocked, unless noted) · ✗ = never allowed for that kind.

Action card deck die token board bag custom card-holder button
lift
flip
rotate
lock [1]
unlock [2]
tap [3]
untap [3]
shuffle [6]
draw
deal
split [4]
combine
roll
press [7]
flick [5]
reveal-all
reveal-team-a
reveal-team-b
peek [8]
search [9]
search-pull [9]
search-close [9]
delete

[1] lock — allowed only while the target is not already locked (!target.locked). [2] unlock — allowed only while the target is locked; it is one of the two actions that bypass the "locked refuses everything" rule above (press is the other). [3] tap/untap — refused for every kind: neither has a case in the switch, so both fall to the default: return false. The runtime still applies tap/untap correctly when an action reaches it some other way (a table script, the host's own UI) — only the participant gate is missing them. See Action vocabularies for the full "known gap" writeup; this page states the fact, that page explains why it happened. [4] split — allowed for deck only while stackCount > 1 (target.kind === "deck" && target.stackCount > 1); a single-card deck cannot be split. [5] flick — the one action with an explicit exclusion rather than an inclusion list: target.kind !== "board". Every kind except board allows it. [6] shuffledeck only, and it is one of two actions the runtime refuses as well as the gate (press is the other): see the by-design note below. [7] pressbutton only (target.kind === "button"), and it is exempt from the locked gate. Like shuffle, the runtime refuses it host-side for any other kind, so a table script or host-side mod pressing a card does nothing — no state change, no sound, no onPressed. A player cannot request press on a non-button either; it is the button object's own click that raises it. A button can further restrict who may press it (metadata.button.restrictTo), checked when the action is applied. [8] peekdeck/bag only. It privately reveals the top N cards of a container to the peeking player alone (host-authoritative, un-redacted for that one viewer's snapshot) and logs one public, identity-free line naming only the count and the deck. A mod cannot request it — it is a UI privacy action, not on the mod allowlist — but a table script may observe it via onObjectAction. It carries a count on the object-action intent; every other action ignores that field. [9] search / search-pull / search-closedeck/bag only, for the same reason peek is. This gate is about the target kind alone: who may search is a second, authored question (the pile's own setting, then its Area zone, then the kind default), re-checked host-side on every one of the three intents, and both gates must pass. A refused search is refused silently. search-pull carries a cardId, validated against that actor's live reveal. A mod cannot request any of them; a table script may observe them, and is never told which card was pulled — see Deck and Bag Search.

By design — shuffle is deck-only, and draw/deal are not. shuffle, draw and deal shared one case in the switch, so a bag was allowed to shuffle. It could not do anything: a bag draws at random (containerDrawMode returns "random" for bag), so there is no order to permute, and shuffleObject yawed the entity 45° and returned. The case is now split — shuffle returns target.kind === "deck", draw/deal still return deck || bag. This is the only row in the matrix where the runtime enforces the same rule independently of the gate: applyObjectAction returns immediately for a non-deck shuffle, so a table script and a host-side mod are refused too, not just a player. Nothing else about bags changed.

Reading the matrix by kind#

Four kinds — token, board, custom, card-holder — share the same narrow "generic manipulation" set: lift, rotate, lock/unlock, flick (except board), delete. Only four kinds get anything beyond that:

  • card additionally allows flip, combine, and all three reveal-* actions.
  • deck allows everything card does, plus shuffle, draw, deal, peek, the three search* actions, and conditionally split.
  • die additionally allows only roll — and, unusually, does not allow flip despite flip toggling a boolean (faceDown) that exists on every kind's underlying state; the gate is deliberately narrower than the schema.
  • bag shares draw, deal, peek and the three search* actions with deck but not shuffle, flip, combine, reveal-*, or split — it is an unordered container: you take things out of it, and what you get is random, so it has no order to shuffle and no card semantics.
  • button adds only press, which no other kind allows. It is otherwise identical to the generic set (and, unlike every other row, press survives the lock).

Reading the matrix by action#

Six actions are universal (lift, rotate, lock, unlock, delete, and flick minus board) and read the same as "works on anything." The rest partition cleanly along one axis each: card semantics (flip, combine, reveal-*card + deck only), container semantics (draw, dealdeck + bag), order semantics (shuffledeck only, because a bag has no order), die semantics (rolldie only), stack semantics (splitdeck, and only a multi-card one), and control semantics (pressbutton only). tap/untap are the only two actions with no kind that allows them through this gate.

Where this is defined#

What File Symbol
The 23-action union packages/shared/src/tableObjects/intents.ts ObjectActionIntent["action"], aliased TableObjectAction
The per-kind gate (this page) packages/shared/src/tableObjects.ts isObjectActionAllowedForTarget
What each action does when applied apps/web/src/playcanvas/TabletopRuntime.ts applyObjectAction
Where the gate is checked apps/web/src/ui/App.tsx the object-action intent branch

See also#

  • Action vocabularies — the engine (19) / table-script (13) / mod (10) vocabularies, ObjectHandle method coverage, and why each narrowing exists.
  • Object kinds — what each kind is and which fields matter for it.
  • IntentsObjectActionIntent, the wire-level intent this gate validates.
  • ObjectAction — the 13 table-script action names.
  • ObjectKind — the kinds this matrix is indexed by.
  • TableObjectAction — all 19 engine actions, including the six no script can request.
  • Mod scripting APIapi.objectAction, the mod call and the 10 names it accepts.