skills/pixijs-scene-core-concepts/references/container-hierarchy.md
The PixiJS scene graph is a tree of Container subclasses rooted at app.stage. Every node has a single parent, zero or more children, and a local transform. Use this reference for mental model and fine-grained parent/child management; for full Container API (constructor options, lifecycle methods), see the top-level pixijs-scene-container skill.
const world = new Container({ label: "world" });
app.stage.addChild(world);
const hero = new Container({ label: "hero" });
hero.addChild(new Sprite(bodyTexture));
hero.addChild(new Sprite(weaponTexture));
world.addChild(hero);
const enemies = new Container({ label: "enemies" });
world.addChild(enemies);
enemies.addChild(new Sprite(enemyTexture), new Sprite(enemyTexture));
Every display object in v8 is a Container subclass. Leaves (Sprite, Graphics, Text, Mesh, ParticleContainer, DOMContainer, GifSprite) have allowChildren = false and must not have children. Use a plain Container to group leaves.
// Add (variadic, returns the first added child)
parent.addChild(child1, child2, child3);
// Insert at index
parent.addChildAt(child, 0);
// Remove
parent.removeChild(child);
parent.removeChildAt(0);
parent.removeChildren();
parent.removeChildren(0, 3); // first three
// Swap
parent.swapChildren(childA, childB);
addChild with an existing parent first removes the child from its old parent (no explicit removeChild needed). Returned value is the first added child for chaining.
When addChildAt moves a child that is already in the same container, the move is silent: no added / removed / childAdded / childRemoved events fire, because the parent-child relationship hasn't changed.
parent.replaceChild(oldChild, newChild);
replaceChild swaps oldChild for newChild at the same index and copies the old child's local transform (position, rotation, scale, etc.) onto the replacement. Use this when you want a drop-in replacement to inherit the old child's placement without copying fields by hand.
const count = parent.children.length;
const first = parent.getChildAt(0);
const index = parent.getChildIndex(child);
const hero = parent.getChildByLabel("hero");
const heroDeep = parent.getChildByLabel("weapon", true); // recursive
const enemies = parent.getChildrenByLabel("enemy");
const waves = parent.getChildrenByLabel(/^wave-\d+/);
const buttonsDeep = parent.getChildrenByLabel("button", true);
label can be a string or a RegExp (e.g., parent.getChildByLabel(/^hero-/)). getChildByLabel returns the first match; getChildrenByLabel returns every match. Both walk the immediate children and recurse when the second argument is true. For more complex queries, iterate parent.children manually.
for (const child of parent.children) {
child.alpha = 0.5;
}
parent.children.forEach((child, i) => {
child.y = i * 32;
});
children is a plain array. It's safe to iterate, but mutating it during iteration (via addChild / removeChild) is not; snapshot first with [...parent.children] if you need to modify the list.
newParent.reparentChild(child);
newParent.reparentChildAt(child, 0);
newParent.reparentChild(childA, childB, childC);
reparentChild and reparentChildAt move children to a new parent while preserving their world transform, so the visuals don't jump. reparentChild appends to the end and accepts multiple children; reparentChildAt inserts at a specific index and accepts one child.
If you must do this manually (for example, to batch with other transform work), convert through getGlobalPosition and toLocal:
const globalPos = child.getGlobalPosition();
newParent.addChild(child);
child.position = newParent.toLocal(globalPos);
Plain addChild keeps the local transform, which means the visual position changes; reparentChild is almost always what you want.
branch.destroy({ children: true });
destroy() unlinks a single node. destroy({ children: true }) recursively tears down the entire sub-tree. Always use { children: true } when removing a branch; otherwise you leak the child nodes and their textures.
const panel = new Container({ label: "panel" });
panel.addChild(new Container({ label: "header" }));
panel.addChild(new Container({ label: "body" }));
const header = panel.getChildByLabel("header");
Use label for debug tooling and light-weight tree navigation. Don't use it for hot-path code; the getChildByLabel walk is O(n) per call.
Containers emit events for hierarchy changes, visibility changes, and destruction.
Parent-side events fire on the container whose children changed:
group.on("childAdded", (child, parent, index) => {
/* ... */
});
group.on("childRemoved", (child, parent, index) => {
/* ... */
});
Child-side events fire on the child itself when its parent changes:
sprite.on("added", (parent) => {
/* ... */
});
sprite.on("removed", (oldParent) => {
/* ... */
});
Property and lifecycle events:
container.on("visibleChanged", (visible) => {
/* ... */
});
container.on("destroyed", (container) => {
/* ... */
});
visibleChanged fires whenever container.visible flips. destroyed fires inside destroy() after internal cleanup but before listeners are removed. By the time destroyed runs, position, scale, pivot, origin, skew, and parent have already been nulled, and children has been emptied (length 0, but the array reference itself is not nulled). Capture any container state you need before calling destroy(), not inside the event handler.
Wrong:
sprite.addChild(otherSprite);
Correct:
const group = new Container();
group.addChild(sprite, otherSprite);
Sprite, Graphics, Text, Mesh, ParticleContainer, DOMContainer, and GifSprite all set allowChildren = false. Adding children logs a deprecation warning and will become a hard error. Use a plain Container to group.
children: trueWrong:
levelContainer.destroy();
Correct:
levelContainer.destroy({ children: true });
Plain destroy() only removes the parent. Its children become orphans; still in memory, still referencing textures. For a clean teardown, always pass { children: true }, and include texture: true / textureSource: true when you also want to release GPU memory.
children during iterationWrong:
parent.children.forEach((child) => {
if (shouldRemove(child)) parent.removeChild(child);
});
Correct:
for (const child of [...parent.children]) {
if (shouldRemove(child)) parent.removeChild(child);
}
removeChild splices the array, shifting indices. Iterating the live array misses elements or processes some twice. Snapshot before iterating.