Dicey Table

Parenting

Parenting locks entities together so they move, rotate and scale as one — a player-seat assembly, a token glued to its base, a board and its fixed markers. Set an entity's Parent in the Inspector, or drag its Hierarchy row onto another entity's row.

Parenting is transform and organization. It is not a physics constraint. If what you actually want is two pieces that both keep simulating but stay connected — a hinge, a tow-rope, a door — you want a Joint, not a parent. Read the whole page before reaching for parenting on a physics problem; several of the caveats below exist precisely because parenting and a physics constraint look similar from the Hierarchy but behave very differently on the table.

<!-- screenshot: editor/working/parenting.png -->

Physics caveat — read this before you parent anything with mass. A parented child is never an independent dynamic body. See the next section.

A parented child is never an independent dynamic body#

attached mode is the default reparent mode (mode?: "attached" | "weld" on ReparentIntent, packages/shared/src/tableObjects/intents.ts). Under it, every parented child is held kinematic — its position is written from its parent's transform every frame, not simulated. This is enforced in one place, applyLockedBodyType (apps/web/src/playcanvas/TabletopRuntime.ts), specifically its definition.parentId branch:

A dynamic child of a moving parent would fight its parent and jitter, drift or detach, because pc.RigidBody writes the entity's world transform from Ammo every frame.

Two things follow, and both are load-bearing:

  • The authored physics.bodyType is left untouched. Parenting overrides only the effective body type. Unparent the entity and its authored type is re-derived — nothing to restore, nothing to drift, because nothing was overwritten.
  • Every restore path inherits this, because every body-type restore in the runtime funnels through the same applyLockedBodyType function — lock/unlock, the edit-mode freeze, weld/unweld, snapshot rebuild. There is no second code path that could disagree with it.

Kinematic bodies push, but are not pushed back#

A parented assembly (in attached mode) is kinematic, and Ammo kinematic bodies affect dynamic ones without being affected in return. A parented board-and-pieces assembly shoves a loose die out of the way when it's dragged across it — but the die never pushes the assembly back, however hard it's thrown. If you need the assembly to actually receive an impact (bounce, get nudged), that is what Welding is for, not parenting.

Grabbing inside an assembly#

Grabbing any member of a parented assembly escalates to the root ancestor by default — "I glued this token to its base; moving it should move the base." Hold Alt while grabbing to target the clicked child directly instead of the assembly root.

  • Desktop: resolveGrabTarget(object, { escalate: !event.altKey }) (TabletopRuntime.ts, pickGrabTarget) — Alt sets escalate: false, which returns the clicked object unchanged.

  • Escalation stops at the first ancestor that is locked, or that fails the actor's canDragObject gate. The walk in resolveGrabTarget (TabletopRuntime.ts) checks each ancestor in turn and returns the last one that passed, rather than jumping straight to the true root — grabbing a token inside a zone-restricted board hands you the token or an intermediate node, never a target you were never allowed to move.

  • VR has no Alt key, so escalation there is unconditional — every VR grab entry point (ray grab, pinch grab, the selection-ray probe) shares one gate, xrGrabbableObject (TabletopRuntime.ts), which calls resolveGrabTarget(picked) with no escalate option, defaulting to true. This is deliberate: if VR did not escalate the same way desktop does, a parented child would behave differently in a headset than on screen — a divergence that would be hard to reproduce and worse to debug. In VR, grab the assembly and use the editor to detach a piece you need independently movable, or mark it grabbableWhileParented (below).

    Unvalidated. VR grab escalation is derived from reading xrGrabbableObject and resolveGrabTarget — it has not been walked on a physical headset. Treat the "desktop and VR cannot diverge" claim above as code-derived, not human-confirmed.

Per-child opt-out: grabbableWhileParented#

One member of an assembly can be exempted from escalation without detaching it — a lid on a box, a dial on a board. Turn on Grabbable in the Inspector's Parent field group (shown only once the entity has a parent) to set the metadata flag grabbableWhileParented (GRABBABLE_WHILE_PARENTED_KEY, packages/shared/src/objectParenting.ts). It rides metadata rather than a schema field of its own — a rare per-object authoring preference with no cross-boundary contract, and metadata already replicates and persists like any other field.

Two things it does not exempt:

  • An ancestor's lock or permission. resolveGrabTarget checks hasRestrictedAncestor before honouring the flag (TabletopRuntime.ts) — a child of a locked or zone-restricted parent stays restricted regardless of the flag. Otherwise the flag would be a way around the very restriction the parent exists to impose.
  • A welded assembly. resolveGrabTarget also checks !this.isWeldedMember(object) — welded members are not separate physics bodies at all, so there is nothing for the flag to exempt. See Welding.

Parenting vs a Fixed Joint#

The Inspector surfaces the "you probably want a Joint, not a parent" guidance at the exact moment it matters, rather than leaving you to discover it from unexpected physics. When both an entity and its parent have physics.bodyType dynamic, a warning appears right below the Parent field (TableEditModeShell.tsx, the em-hint-warn paragraph in the Inspector's entity section):

"Both pieces are dynamic. Parenting holds this one kinematic so it cannot fight its parent — if you want both to keep simulating and stay connected, use a Fixed Joint instead, or turn on Weld on the parent to make the assembly a single physics body."

That message is the one-line decision rule in prose form:

Parenting gives a child a parent transform (and makes it kinematic). Weld merges children into the parent's single compound rigidbody. Joints connect two independently-dynamic bodies with an Ammo constraint.

Non-uniform parent scale distorts children#

A parent's scale multiplies every descendant's world scale. A card is roughly {1, 0.02, 1.4} in world units — so a card makes a terrible parent: anything you parent to it inherits that near-zero Y scale and flattens. This is a design caveat, not a live bug — the runtime correctly restores each descendant's world position, rotation and scale on reparent, so nothing is silently lost. It is a consequence of how transform propagation works, and the fix is authoring discipline: parent things to boards, bases and tokens, not to thin flat objects.

What is not supported#

  • Sibling ordering. There is no supported way to control the order children of the same parent are listed or processed in, and this is not a phantom contract waiting to be filled in — array order in the scene document is authoritative nowhere in the runtime.
  • Chains deeper than MAX_PARENT_DEPTH = 8 (objectParenting.ts). PlayCanvas's own guidance is to keep hierarchies shallow; beyond 8 levels both the transform cost and the Inspector experience stop making sense. A drop that would exceed the limit — counting the whole subtree being moved, not just the dragged entity — is rejected as "too-deep" before it happens.
  • Cycles, rejected in three places for defense in depth: the editor (an invalid drop target is not droppable), the host's reparent-intent handler (rejects with a reason), and migrateTableSnapshot's load path (pruneParentLinks, objectParenting.ts) — so a hand-edited or corrupted save can never wedge the runtime in an infinite parent walk.

Deleting a parent#

Deleting an entity with children prompts you to choose, rather than guessing:

"<label> has N child entities. OK — delete this entity AND its children. Cancel — delete only this entity (children move up to its parent)."

The least destructive option — Cancel, children move up one level — is the default behavior both here and in the runtime's own delete path (detachChildrenOfRemovedObject, TabletopRuntime.ts), so an accidental Delete on a parent never silently takes an entire assembly with it. The same "children move up" default applies to the multi-select Delete N action described on Multi-select.

Unvalidated behavior#

Per the project's own status notes, two-peer replication, host migration, and a physics-jitter pass on a parented assembly have not been walked in a browser. Everything above is derived from reading TabletopRuntime.ts and objectParenting.ts directly and is believed accurate to the code, but multiplayer and VR claims specifically should be treated as code-derived, not human-confirmed, until browser-validator exercises them.

See also#

  • Welding — merging a parented assembly into one physics body.
  • Multi-select — drag-to-reparent a whole selection at once.
  • The Scene Model — the one-line decision rule, in context.
  • Mod scripting APITableObjectState.parentId, what a mod can and cannot see of this hierarchy.
  • ObjectHandleObjectData.position is world-absolute even for a child, and destroy takes descendants with it.