Parent-child structure, transform inheritance, render order, masks, and how the tree stays composable.
Scene graph
A scene’s drawables form a tree: every renderable node has zero or one parent and zero or more children. Transforms cascade down the tree, drawing follows the tree’s order, and you compose larger groups by nesting smaller ones. The result is that “this object follows that object” stops being a special case — it’s the default.
Containers and nodes
Two key types: RenderNode is the abstract base for everything that can be drawn. Container is a RenderNode that holds children. Sprites, text, graphics, and meshes are all render nodes; containers are how you group them.
Every scene starts with one container already in place — this.root. Adding a node via this.addChild(...) is shorthand for this.root.addChild(...):
init(loader) {
this.hero = new Sprite(loader.get(Texture, 'hero'));
this.coin = new Sprite(loader.get(Texture, 'coin'));
this.addChild(this.hero);
this.addChild(this.coin);
}
For larger scenes, build sub-containers and add nodes to them instead:
init(loader) {
this.world = new Container();
this.hud = new Container();
this.world.addChild(new Sprite(loader.get(Texture, 'level')));
this.hud.addChild(new Sprite(loader.get(Texture, 'health-bar')));
this.addChild(this.world);
this.addChild(this.hud);
}
Containers can be added, removed, and moved between parents at any time. The hierarchy is your scene’s structure.
When a parent transforms, its children move with it. If you set the parent’s position to (100, 0) and a child’s position to (20, 0), the child renders at world position (120, 0):
this.player = new Container();
this.player.setPosition(100, 0);
const body = new Sprite(loader.get(Texture, 'body'));
const head = new Sprite(loader.get(Texture, 'head'));
head.setPosition(0, -32);
this.player.addChild(body);
this.player.addChild(head);
this.addChild(this.player);
Now moving this.player moves the head and body together. Rotating the player rotates them around the player’s pivot. The same applies to scale.
Each node carries its own transform — helpers such as setPosition, setRotation, setScale, setSkew, and setOrigin describe where the node lives relative to its parent (positions are pixels, rotations and skew angles are degrees). Sprites add setAnchor(...) on top of that, which controls which point of the texture is placed at the sprite’s position. Local transforms are what you set; the global transform is the local one composed with every ancestor up the tree, which is what the renderer uses.
Skew
Skew (shear) slants the local shape along one or both axes without scaling or rotating it. Two properties control it:
skewX — shear along the X axis: positive values lean the top edge to the right.
skewY — shear along the Y axis: positive values lean the left edge downward.
Both are in degrees, consistent with rotation. Use the compound setter when setting both at once:
hero.skewX = 15; // lean right 15°
hero.skewY = -5; // tilt top-left slightly
hero.setSkew(15, -5); // same as above
hero.setSkew(10); // sets both skewX and skewY to 10
Skew composes with position, rotation, scale, origin, and anchor. It cascades to children and invalidates bounds exactly like any other transform component. Any node with a non-zero skew is no longer axis-aligned — its bounds become the AABB of the skewed shape, and collision/hit-testing uses the exact parallelogram geometry.
Common uses:
- Pseudo-3D effects — slant a floor or wall tile without a shader.
- UI slant — angled buttons or callout boxes.
- Squash/stretch animation — combine with scale for directional deformation.
- Impact lean — have a character sprite lean into movement or absorb a hit.
Render order
Children render in the order they were added. The first child drawn ends up at the back; the last child drawn ends up on top. To put a sprite in front of another, add it later — or change its position in the parent’s child list:
this.world.addChild(this.background);
this.world.addChild(this.player); // drawn over background
this.world.addChild(this.foreground); // drawn over player
For dynamic z-order — characters that need to layer correctly based on their y position — assign zIndex to each child. ExoJS resolves sibling order from zIndex during render-plan playback (tie-breaker: child list order), without mutating container.children.
Adding, removing, and rearranging
The container API covers the common cases:
parent.addChild(child); // append (variadic — pass multiple)
parent.addChildAt(child, index); // insert at a specific index
parent.removeChild(child); // remove by reference
parent.removeChildAt(index); // remove by index
parent.removeChildren(); // clear all children
parent.swapChildren(a, b); // swap two children's positions
parent.setChildIndex(child, index); // move a child to a new position
Removing a child doesn’t destroy it — you can keep the reference and re-add it later. Destroy the node separately when it should no longer own resources or participate in the scene at all.
Why this stays composable
Because transforms cascade and children render in order, large scenes are built by nesting smaller scenes. A Player container groups body, weapon, hat, and effects. A World container groups player, enemies, and tiles. A Game scene groups world and HUD.
Each level is independent: rotating the player rotates everything inside it without the scene knowing how the player is structured internally. Replacing the player’s hat doesn’t touch any other code. Adding a “shake the world” effect is one rotation tween on the world container.
Masks
Each render node has a mask property that clips its rendering to a shape. The mask source can be a Rectangle, a Texture, another RenderNode, or null (no mask). Masks are applied during render, not during transform — children of a masked container are still positioned normally, but only the visible region is drawn.
import { Rectangle } from '@codexo/exojs';
this.viewport = new Container();
this.viewport.mask = new Rectangle(0, 0, 400, 300);
this.viewport.addChild(this.world);
this.addChild(this.viewport);
This pattern is useful for HUD windows, minimaps, and any area where you want to clip a complex subtree to a simple shape.
Examples
import { Application, Color, Container, Scene, Sprite, Texture } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class ContainersScene extends Scene {
private rainbow!: Sprite;
private bunnies!: Container;
override async load(loader): Promise<void> {
await loader.load(Texture, {
bunny: 'image/ship-a.png',
rainbow: 'image/hue-ramp.png',
});
}
override init(loader): void {
const { width, height } = this.app.canvas;
this.rainbow = new Sprite(loader.get(Texture, 'rainbow'));
this.bunnies = new Container();
this.bunnies.setPosition((width / 2) | 0, (height / 2) | 0);
for (let i = 0; i < 25; i++) {
const bunny = new Sprite(loader.get(Texture, 'bunny'));
bunny.setPosition((i % 5) * (bunny.width + 15), ((i / 5) | 0) * (bunny.height + 10));
this.bunnies.addChild(bunny);
}
this.bunnies.setAnchor(0.5);
}
override update(delta): void {
const bounds = this.bunnies.getBounds();
this.rainbow.x = bounds.x;
this.rainbow.y = bounds.y;
this.rainbow.width = bounds.width;
this.rainbow.height = bounds.height;
this.bunnies.rotate(delta.seconds * 36);
}
override draw(context): void {
context.backend.clear();
context.render(this.rainbow);
context.render(this.bunnies);
}
}
app.start(new ContainersScene());
Two containers, each holding several sprites. Rotating the parent moves the whole group.
import { Application, Color, Container, Graphics, Scene } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class NestedTransformsScene extends Scene {
private sun!: Graphics;
private planetOrbit!: Container;
private planet!: Graphics;
private moonOrbit!: Container;
private moon!: Graphics;
override init(): void {
const { width, height } = this.app.canvas;
this.sun = new Graphics();
this.sun.fillColor = new Color(255, 220, 90);
this.sun.drawCircle(0, 0, 30);
this.planetOrbit = new Container().setPosition(width / 2, height / 2);
this.planet = new Graphics();
this.planet.fillColor = new Color(120, 190, 255);
this.planet.drawCircle(0, 0, 16);
this.planet.setPosition(220, 0);
this.moonOrbit = new Container().setPosition(220, 0);
this.moon = new Graphics();
this.moon.fillColor = new Color(220, 220, 220);
this.moon.drawCircle(0, 0, 8);
this.moon.setPosition(44, 0);
this.planetOrbit.addChild(this.sun);
this.planetOrbit.addChild(this.planet);
this.planetOrbit.addChild(this.moonOrbit);
this.moonOrbit.addChild(this.moon);
}
override update(delta): void {
this.planetOrbit.rotate(delta.seconds * 30);
this.planet.rotate(delta.seconds * 120);
this.moonOrbit.rotate(delta.seconds * 180);
}
override draw(context): void {
context.backend.clear();
context.render(this.planetOrbit);
}
}
app.start(new NestedTransformsScene());
A solar-system style hierarchy: planet rotates around sun, moon rotates around planet.
import { Application, Color, Container, Scene, Sprite, Text, Texture } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class LocalVsGlobalTransformScene extends Scene {
private parent!: Container;
private localSprite!: Sprite;
private globalSprite!: Sprite;
private localLabel!: Text;
private globalLabel!: Text;
override async load(loader): Promise<void> {
await loader.load(Texture, { bunny: 'image/ship-a.png' });
}
override init(loader): void {
const { width, height } = this.app.canvas;
const texture = loader.get(Texture, 'bunny');
this.parent = new Container().setPosition(width / 4, height / 2);
this.localSprite = new Sprite(texture)
.setAnchor(0.5)
.setScale(0.8)
.setPosition(160, 0)
.setTint(new Color(120, 190, 255));
this.globalSprite = new Sprite(texture)
.setAnchor(0.5)
.setScale(0.8)
.setPosition((width * 3) / 4, height / 2)
.setTint(new Color(255, 190, 120));
this.parent.addChild(this.localSprite);
this.localLabel = new Text('inherited rotation', { fillColor: Color.white, fontSize: 16 });
this.localLabel.setPosition(width / 4 - 60, height / 2 - 220);
this.globalLabel = new Text('screen-space', { fillColor: Color.white, fontSize: 16 });
this.globalLabel.setPosition((width * 3) / 4 - 50, height / 2 - 220);
}
override update(delta): void {
this.parent.rotate(delta.seconds * 60);
}
override draw(context): void {
context.backend.clear();
context.render(this.parent);
context.render(this.globalSprite);
context.render(this.localLabel);
context.render(this.globalLabel);
}
}
app.start(new LocalVsGlobalTransformScene());
The same drawable shown twice — once inside a transformed parent, once at root — to make the difference between local and global transforms visible.
import { Application, Color, Graphics, Scene, Sprite, Text, Texture } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
const modes = [
{ name: 'corner', anchor: [0, 0] as [number, number], origin: [0, 0] as [number, number] | null },
{ name: 'center', anchor: [0.5, 0.5] as [number, number], origin: null },
{ name: 'off-canvas', anchor: [0.5, 0.5] as [number, number], origin: [180, -80] as [number, number] | null },
];
class PivotAndAnchorScene extends Scene {
private sprite!: Sprite;
private pivotMarker!: Graphics;
private label!: Text;
private mode = 0;
private timer = 0;
override async load(loader): Promise<void> {
await loader.load(Texture, { bunny: 'image/ship-a.png' });
}
override init(loader): void {
const { width, height } = this.app.canvas;
this.sprite = new Sprite(loader.get(Texture, 'bunny')).setPosition(width / 2, height / 2);
this.pivotMarker = new Graphics();
this.label = new Text('', { fillColor: Color.white, fontSize: 18 });
this.label.setPosition(20, 20);
this.applyMode();
}
private applyMode(): void {
const mode = modes[this.mode];
this.sprite.setAnchor(mode.anchor[0], mode.anchor[1]);
if (mode.origin) this.sprite.setOrigin(mode.origin[0], mode.origin[1]);
this.label.text = `mode: ${mode.name}`;
}
override update(delta): void {
this.timer += delta.seconds;
this.sprite.rotate(delta.seconds * 90);
if (this.timer > 1.8) {
this.timer = 0;
this.mode = (this.mode + 1) % modes.length;
this.applyMode();
}
}
override draw(context): void {
const m = this.sprite.getGlobalTransform();
context.backend.clear();
context.render(this.sprite);
this.pivotMarker.clear();
this.pivotMarker.fillColor = new Color(255, 80, 80);
this.pivotMarker.drawCircle(m.x, m.y, 5);
context.render(this.pivotMarker);
context.render(this.label);
}
}
app.start(new PivotAndAnchorScene());
How setOrigin and setAnchor change where a node rotates and where its position points.
import { Application, Color, Container, Keyboard, Scene, Sprite, Text, Texture } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class ZOrderingScene extends Scene {
private group!: Container;
private label!: Text;
private sprites!: Sprite[];
override async load(loader): Promise<void> {
await loader.load(Texture, { bunny: 'image/ship-a.png' });
}
override init(loader): void {
const { width, height } = this.app.canvas;
this.group = new Container();
this.label = new Text('Press 1, 2, 3', { fillColor: Color.white, fontSize: 18 });
this.label.setPosition(18, 18);
this.sprites = [0, 1, 2].map(index => {
const sprite = new Sprite(loader.get(Texture, 'bunny'))
.setAnchor(0.5)
.setScale(0.9)
.setPosition(width / 2 - 60 + index * 60, height / 2);
sprite.setTint([new Color(255, 120, 120), new Color(120, 255, 170), new Color(120, 170, 255)][index]);
sprite.zIndex = index;
this.group.addChild(sprite);
return sprite;
});
this.inputs.onTrigger(Keyboard.One, () => this.setFront(0));
this.inputs.onTrigger(Keyboard.Two, () => this.setFront(1));
this.inputs.onTrigger(Keyboard.Three, () => this.setFront(2));
}
private setFront(index: number): void {
this.sprites.forEach((sprite, i) => {
sprite.zIndex = i === index ? 3 : i;
});
}
override draw(context): void {
context.backend.clear();
context.render(this.group);
context.render(this.label);
}
}
app.start(new ZOrderingScene());
Using zIndex for dynamic depth ordering.
import { Application, Color, Graphics, Rectangle, Scene, Sprite, Texture } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
});
const ALPHA_RINGS = assets.technical.alpha.alphaGradientRings;
class MasksScene extends Scene {
private rectSprite!: Sprite;
private rectMask!: Rectangle;
private gfxSprite!: Sprite;
private time = 0;
override async load(loader): Promise<void> {
await loader.load(Texture, { alphaRings: ALPHA_RINGS });
}
override init(loader): void {
const { width, height } = this.app.canvas;
const tex = loader.get(Texture, 'alphaRings');
this.rectSprite = new Sprite(tex);
this.rectSprite.setScale(1);
this.rectSprite.setPosition((width / 4) | 0, (height / 2) | 0);
this.rectSprite.setAnchor(0.5);
this.rectMask = new Rectangle(0, 0, 110, 110);
this.rectSprite.mask = this.rectMask;
const circle = new Graphics();
circle.fillColor = Color.white;
circle.drawCircle(0, 0, 72);
this.gfxSprite = new Sprite(tex);
this.gfxSprite.setScale(1);
this.gfxSprite.setPosition(((width * 3) / 4) | 0, (height / 2) | 0);
this.gfxSprite.setAnchor(0.5);
this.gfxSprite.mask = circle;
}
override update(delta): void {
const { width, height } = this.app.canvas;
this.time += delta.seconds;
const r = 80;
this.rectMask.x = (width / 4 + Math.cos(this.time * 1.4) * r - 55) | 0;
this.rectMask.y = (height / 2 + Math.sin(this.time * 1.4) * r - 55) | 0;
}
override draw(context): void {
context.backend.clear();
context.render(this.rectSprite);
context.render(this.gfxSprite);
}
}
app.start(new MasksScene());
A Rectangle mask animates across a sprite to reveal it progressively.
Where to go next
The next chapter, Coordinates and views, covers world-vs-screen coordinates, the camera (View), and how to handle different canvas sizes without breaking layout assumptions.