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
unlockandpress. The matrix below assumes the target is unlocked, except in theunlockrow.pressis 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 alwaysfalse.
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] shuffle — deck
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] press — button 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] peek — deck/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-close —
deck/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 —
shuffleis deck-only, anddraw/dealare not.shuffle,drawanddealshared onecasein the switch, so abagwas allowed to shuffle. It could not do anything: a bag draws at random (containerDrawModereturns"random"forbag), so there is no order to permute, andshuffleObjectyawed the entity 45° and returned. The case is now split —shufflereturnstarget.kind === "deck",draw/dealstill returndeck || bag. This is the only row in the matrix where the runtime enforces the same rule independently of the gate:applyObjectActionreturns immediately for a non-deckshuffle, 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:
cardadditionally allowsflip,combine, and all threereveal-*actions.deckallows everythingcarddoes, plusshuffle,draw,deal,peek, the threesearch*actions, and conditionallysplit.dieadditionally allows onlyroll— and, unusually, does not allowflipdespitefliptoggling a boolean (faceDown) that exists on every kind's underlying state; the gate is deliberately narrower than the schema.bagsharesdraw,deal,peekand the threesearch*actions withdeckbut notshuffle,flip,combine,reveal-*, orsplit— 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.buttonadds onlypress, which no other kind allows. It is otherwise identical to the generic set (and, unlike every other row,presssurvives 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, deal → deck + bag), order semantics
(shuffle → deck only, because a bag has no order), die semantics
(roll → die only), stack semantics (split → deck, and only a multi-card
one), and control semantics (press → button 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,
ObjectHandlemethod coverage, and why each narrowing exists. - Object kinds — what each kind is and which fields matter for it.
- Intents —
ObjectActionIntent, 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 API —
api.objectAction, the mod call and the 10 names it accepts.
