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.RigidBodywrites the entity's world transform from Ammo every frame.
Two things follow, and both are load-bearing:
- The authored
physics.bodyTypeis 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
applyLockedBodyTypefunction — 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 setsescalate: false, which returns the clicked object unchanged. -
Escalation stops at the first ancestor that is locked, or that fails the actor's
canDragObjectgate. The walk inresolveGrabTarget(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 callsresolveGrabTarget(picked)with noescalateoption, defaulting totrue. 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 itgrabbableWhileParented(below).Unvalidated. VR grab escalation is derived from reading
xrGrabbableObjectandresolveGrabTarget— 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.
resolveGrabTargetcheckshasRestrictedAncestorbefore 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.
resolveGrabTargetalso 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.tsandobjectParenting.tsdirectly and is believed accurate to the code, but multiplayer and VR claims specifically should be treated as code-derived, not human-confirmed, untilbrowser-validatorexercises 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 API —
TableObjectState.parentId, what a mod can and cannot see of this hierarchy. ObjectHandle—ObjectData.positionis world-absolute even for a child, anddestroytakes descendants with it.
