Intents
A TableIntent is a single described mutation of table state. In the
host-authoritative model, a player or
spectator never mutates shared state directly — it sends an intent to the host, and the
host's IntentDispatcher (apps/web/src/playcanvas/intent/IntentDispatcher.ts) applies it
and broadcasts the resulting TableSnapshot.
Verified count: 26#
TableIntent (packages/shared/src/tableObjects/intents.ts) is a union of 26 member
types. The dispatcher's switch (intent.type) is exhaustive over the union (TypeScript's
never check at its default case proves it — IntentDispatcher.ts), and counting its
case labels gives the same 26: 22 handled directly, 1 (combine-selection) deliberately
delegated elsewhere, and 3 declared-but-not-implemented.
This page previously said 24 and listed two Zone* intents. Both were wrong at the same
time and they cancelled out, which is why the total looked stable: the replicated
TableSnapshot.zones model was retired on 2026-07-30 (zone authoring is Edit-Mode-only now),
taking ZoneUpsertIntent/ZoneDeleteIntent with it, while DeckLoadIntent,
CardOverlaySetIntent, SurfacePressIntent and SeatHoldersReconcileIntent had been added
and never documented. Source wins; the sections below are counted against it.
By area#
Object lifecycle and manipulation (7)#
| Intent | Fields | What it does |
|---|---|---|
SpawnIntent |
object: TableObjectDefinition |
Creates a new entity. |
ReparentIntent |
objectId, parentId: string | null, mode?: "attached" | "weld" |
Re-parents an entity (or detaches it to the root with parentId: null). Host-validated against cycles, self-parenting, missing parents and over-deep chains. Transform and organization only — not a physics constraint; see Joint for that. |
ObjectActionIntent |
objectId, action (one of 19) |
Applies one of the 19 object actions. |
CombineSelectionIntent |
objectIds: string[], faceDown: boolean |
Merges a whole held card/deck multi-selection into one shuffled deck (the shake-to-combine gesture). Handled directly in TabletopRuntime.applyIntent — not in IntentDispatcher — because it needs the full object lifecycle (merge, destroy, createObject). Host-authoritative specifically so the merge is seeded and shuffled once, rather than every client shuffling to its own order. |
DragIntent |
objectId, position, rotation?, release?, noSnap? |
Streams (or commits, with release: true) a drag. rotation is optional so a non-host drop can still commit a final orientation without waiting on the last drag-update tick. |
TransformIntent |
objectId, position?, rotation?, scale?, release? |
Sets an entity's transform directly (editor gizmo moves), independent of the drag gesture. |
DeckLoadIntent |
source, deckId?, deckVersion?, name, decklist, defaultPartitionId?, partitionOrder?, position, faceDown? |
Materializes a saved deck as one deck pile per partition — never as N loose cards. The decklist travels inside the intent, because deck rows are owner-authoritative and default to private, so the host cannot fetch someone else's deck. That makes this the one intent whose size a remote peer chooses: it is capped at 600 lines / 1,000 cards per pile / 2,000 per load / 10,000 card entries on the table, and any breach refuses the whole load rather than truncating it. Refused for a spectator unconditionally — ahead of even spectatorCanInteract — and for a player without playerCanSpawn. A spawned pile carries card ids only. |
Cards (1)#
| Intent | Fields | What it does |
|---|---|---|
CardOverlaySetIntent |
objectId, target? ("value" | "counter"), key?, op, value? |
Sets, adds to, clears or resets one overlay value or counter count on a card. Only the mutable VALUES replicate — the regions, formats and counter art that render them are resolved locally from the mod's data/cardSchema.json. Refused for any key the schema does not declare, in the host's handler. |
Prop surfaces (1)#
| Intent | Fields | What it does |
|---|---|---|
SurfacePressIntent |
objectId, surfaceId, u, v |
Reports a pointer press on a prop surface as a surface-normalised hit point. Deliberately not a widget id: the HOST re-resolves (u, v) against its own copy of the layout, so a peer cannot fabricate a press on a widget that is not there. |
Host-only (1)#
| Intent | Fields | What it does |
|---|---|---|
SeatHoldersReconcileIntent |
seats?, reason |
Re-fits each seat's authored furniture to whoever is actually sitting. Not a request a peer may make — isIntentAllowedForParticipant drops it from any non-host sender before any permission is consulted, because unlike every other variant it creates objects and can evict a seat's rack. |
There is no
ZoneUpsertIntent/ZoneDeleteIntent. The replicatedTableSnapshot.zonesmodel was retired on 2026-07-30; seat zones are authored in Edit Mode and ride the scene, not the intent stream.
World-space and screen UI (2)#
UiElementUpsertIntent { modId, element: TableUiElementDefinition } and
UiElementDeleteIntent { modId, elementId } — a mod's on-table UI panel content.
Snap points (2)#
SnapPointUpsertIntent { snapPoint: TableSnapPointDefinition } and
SnapPointDeleteIntent { snapPointId }.
Vector lines (2)#
VectorLineUpsertIntent { vectorLine: TableVectorLineDefinition } and
VectorLineDeleteIntent { vectorLineId } — freehand/drawn line annotations.
Decals (2)#
DecalUpsertIntent { decal: TableDecalDefinition } and DecalDeleteIntent { decalId }.
Text labels (2)#
TextLabelUpsertIntent { textLabel: TableTextLabelDefinition } and
TextLabelDeleteIntent { textLabelId }.
Joints (2)#
JointUpsertIntent { joint: TableJointDefinition } and JointDeleteIntent { jointId } —
create/remove a physics constraint (fixed / hinge / spring) between two independently
dynamic bodies. Not parenting — see
the parenting/joint/weld split.
Sound (1)#
SetObjectSoundIntent { objectId, action: SoundAction, ref: SoundRef | null } — sets,
replaces, or clears (ref: null) an entity's per-event sound override. A mod may only
author a { kind: "builtin"; material } or { kind: "mod"; modId; name } ref pointing at
its own declared sound — never a raw first-party clip id; that constraint is enforced at
the sandbox surface before the intent is ever dispatched.
Declared but not implemented (3) ⚠#
These three types are real members of the TableIntent union — they will type-check and
they appear in apiSymbols-style introspection of the type — but none of them is
author-reachable and none of them does anything. IntentDispatcher.ts handles
all three identically: it logs Intent type "<type>" is not implemented in gameplay runtime dispatch yet. and returns without mutating state or emitting a snapshot.
| Intent | Fields | Status |
|---|---|---|
EditEntityPatchIntent |
patch: EntityPatch |
Declared, unimplemented. Survives only as a type after its backing mechanism (EditSceneSnapshot.entityPatches) was deleted — see Document vs engine. Pruning it from the union alongside the field was judged more disruptive than leaving a dead type. |
EditSceneSettingsIntent |
environment: Partial<SceneEnvironment> |
Declared, unimplemented as an intent. The environment itself is live — ambient, fog and the skybox all apply — but it is applied by the runtime directly from the scene document (patchEditSceneEnvironment), not by dispatching this intent. Sending it still does nothing. |
EditSpawnPresetIntent |
presetId: StandardObjectPresetId, position: Vector3Tuple |
Declared, unimplemented. Note this is distinct from the everyday "spawn a standard preset" flow, which goes through SpawnIntent with a definition built by buildStandardObjectPresetDefinition() — this type was a separate, never-finished path. |
If you are scanning the source and counting variants: all three live in
packages/shared/src/sceneEditor.ts (not tableObjects/intents.ts, unlike most of the other 23;
CardOverlaySetIntent lives in packages/shared/src/cardOverlays.ts)
and are re-exported into the TableIntent union as SceneEditorEditEntityPatchIntent /
SceneEditorEditSceneSettingsIntent / SceneEditorEditSpawnPresetIntent.
What every implemented intent has in common#
Every mutating path through applyIntent()/the dispatcher's handlers calls pushUndo()
before mutating and ends by emitting a fresh TableSnapshot — so a new intent handler that
skips either step is a bug, not a variant. Player- and spectator-originated intents pass
through a validation context first (createValidationContext in IntentDispatcher); some
intents (zone-*, snap-point-*, vector-line-*, decal-*, text-label-*, joint-*,
ui-element-*, set-object-sound) have no additional per-kind validation beyond that
context, while spawn, object-action, drag, transform and reparent each call a
dedicated validate*Intent function first.
See also#
- Object actions — the
actionvaluesObjectActionIntentcarries. - Object state — the shapes these intents create, update or reference.
- Host authority — why only the host applies an intent.
- Mod scripting API —
api.createObjectandapi.objectAction, the two mod calls that become intents.
