Skip to content

Layers & Draw Order

Render layers control draw order: entities on higher layers render on top. Within a layer, entities paint in insertion order by default — turn on per-frame depth sorting to get correct paint order for a top-down game, where a character standing below a sign should paint over it, and one standing above it should paint under it.

import { Scene } from "@yagejs/core";
import { RendererPlugin, type LayerDef } from "@yagejs/renderer";
class GameScene extends Scene {
readonly name = "game";
readonly layers: readonly LayerDef[] = [
{ name: "background", order: -20 },
{ name: "tiles", order: -10 },
{ name: "characters", order: 0 },
{ name: "fx", order: 10 },
{ name: "ui", order: 100, space: "screen" },
];
}
engine.use(new RendererPlugin({ width: 800, height: 600 }));

Assign a layer via the layer property on SpriteComponent or GraphicsComponent. Within a layer, entities paint in insertion order by default — opt into depth sorting with sort.

A component with no layer renders on the auto-created "default" layer (order 0) that every scene has. You don’t need to declare it — but you can, to configure it (see Sorting the default layer).

Each LayerDef has a space: "world" | "screen" (default "world") that controls whether cameras transform it:

  • "world" — layers scroll and zoom with the camera. Use for gameplay layers (background, tiles, characters, fx), parallax, and entity-anchored UI (interaction prompts, health bars, damage numbers).
  • "screen" — layers stay fixed to the viewport. Use for HUD, menus, dialogs, and any UI you want anchored to the screen. A CameraEntity spawned without explicit bindings skips screen-space layers on auto-bind; you can still bind one explicitly by naming it in bindings.

If no "ui" layer is declared, @yagejs/ui auto-provisions one as space: "screen" the first time a UISurface is added, so HUDs just work without any layer wiring.

See the Camera guide for how camera bindings target layers by space and name, and the UI guide for how UISurface/UIRoot pick between viewport-anchored and Transform-pinned positioning.

By default, sprites within a layer paint in the order their containers were added (insertion order). For top-down 2D games you almost always want characters with a higher position.y to paint over those with a lower y, so the “in front of” relationship looks right. Set LayerDef.sort to a depth-key function (container) => number and DisplaySystem writes the result to each child’s zIndex every frame; Pixi’s render pipeline then orders the layer by zIndex — this is the engine’s Y-sort / depth-sort mechanism:

import { ySort, type LayerDef } from "@yagejs/renderer";
readonly layers: readonly LayerDef[] = [
{ name: "ground", order: -10 },
{ name: "characters", order: 0, sort: ySort },
{ name: "ui", order: 100, space: "screen" },
];

ySort is just (c) => c.position.y — terse enough that you can write your own depth-key if you’d rather drive paint order from a different axis or off a custom field on the sprite.

The most common top-down setup is “depth-sort the layer my entities are already on” — and that layer is usually the auto-created "default". You don’t have to invent a named layer and set layer: "..." on every component: declare a LayerDef named "default" to configure the pre-created layer in place.

import { ySort, type LayerDef } from "@yagejs/renderer";
class TopDownScene extends Scene {
readonly name = "game";
// Sprites added with no `layer` land here and depth-sort by y.
readonly layers: readonly LayerDef[] = [{ name: "default", sort: ySort }];
}

The declared order is ignored — "default" is the order-0 layer by definition. To flip sorting on (or off) after the scene is live, call setSort on the layer (any component can reach the tree through scene scope):

import { SceneRenderTreeKey, ySort } from "@yagejs/renderer";
const tree = this.use(SceneRenderTreeKey); // inside a Component
tree.defaultLayer.setSort(ySort);

Passing undefined to setSort stops the per-frame re-sort, but it does not restore the original insertion order — Pixi reorders the container’s children in place while sorting is on, so clearing sortableChildren only halts further sorting and children keep their last-sorted order. Re-establish paint order yourself if you need the original sequence back.

Depth offsets (ySortBy) — Godot’s y_sort_origin pattern

Section titled “Depth offsets (ySortBy) — Godot’s y_sort_origin pattern”

A sprite’s position.y is its anchor’s y in the world. If your sprites are anchored at the top, the visual “footprint” (where the sprite appears to touch the ground) sits well below position.y, and plain ySort will produce wrong overlaps: a player whose feet are at the bottom of the sprite will sort behind a tree whose trunk is at the bottom of its sprite, even though the player’s feet are geometrically in front of the tree’s trunk.

ySortBy(offsetOf) lets each container advertise a per-sprite Y offset that gets added to position.y before the depth-key is computed. This is the same idea as Godot’s y_sort_origin:

import { ySortBy } from "@yagejs/renderer";
const sort = ySortBy(
(c) => (c as { depthOffset?: number }).depthOffset,
);
// On each sprite that should sort by its visual base:
sprite.sprite.depthOffset = 32; // depth key is `position.y + 32`

offsetOf returns undefined to fall through to plain position.y, so mixed-content layers work without every child carrying a depth offset.

Because sort writes to zIndex, anything that manually sets child.zIndex = N between frames composes naturally — the next DisplaySystem.update overwrites it from the depth-key fn, but between updates Pixi sees whatever you wrote. For occasional one-off biasing (a “pop to front” highlight, a brief “behind everything” fade), just don’t depend on the manual value surviving the next frame. For permanent per-sprite biasing, fold it into the depth-key fn itself via ySortBy.

Keeping multi-part entities together (SortGroupComponent)

Section titled “Keeping multi-part entities together (SortGroupComponent)”

A layer sort keys every visual independently, because each sprite is a flat child of the layer. That’s exactly what you want for a world full of single-sprite entities — but it splits a multi-part entity. Picture a character whose body sits at y = 100 and whose held lantern (a child entity, offset toward the camera) sits at y = 108. Under ySort the two parts get keys 100 and 108, and any unrelated entity at y = 104 sorts between them — the lantern detaches and floats over the intruder while the body stays behind it. The character visually splits in half.

SortGroupComponent fixes this by giving an entity its own stacking context — the same idea as Unity’s SortingGroup or a y-sort-scoped subtree in Godot. The entity’s visuals render into an owned sub-container: they sort among themselves inside it, while the container as a whole takes one slot in the layer’s sort.

import { SortGroupComponent, SpriteComponent } from "@yagejs/renderer";
class Hero extends Entity {
setup() {
this.add(new Transform({ position: { x: 200, y: 200 } }));
// Add the group BEFORE the visuals it should gather.
this.add(new SortGroupComponent({ layer: "world" }));
this.add(new SpriteComponent({ texture: "hero-body", layer: "world" }));
// Child sprites on the same layer join the group automatically:
this.spawnChild("lantern", Lantern); // offset toward the camera
this.spawnChild("hat", Hat);
}
}

Now the whole hero sorts as a single unit at the body’s depth, and the intruder at y = 104 resolves cleanly in front of or behind the entire hero — never between his parts.

A few things worth knowing:

  • The group sorts at the owning entity’s footing. It keys off that entity’s own sprite (so ySort/ySortBy read a real sprite’s position and offset). An entity with no sprite of its own — a purely logical parent that just groups children — falls back to its Transform position, which is usually exactly the right anchor.
  • Members keep their order. By default they render in the order they were added, and you can bias one with a manual zIndex — a genuine stacking context. Pass innerSort: ySort if you’d rather the parts depth-sort among themselves.
  • Only paint order changes. The group container is an invisible identity wrapper; your sprites keep their normal world transforms. Rotating or scaling the parent still moves the children exactly as it did before — that composition lives in the ECS Transform, not the render tree.
  • Layers stay independent. A group gathers subtree visuals on its layer; a child you deliberately put on another layer (a "ground" shadow, say) is left out and sorts on its own. A SortGroupComponent on a descendant entity simply starts its own separate unit.

The tradeoff is the whole point and its own limitation: a grouped entity no longer interleaves with the world part-by-part. A tall tree wrapped in a group can’t let a character walk behind its trunk while passing in front of its canopy — the whole tree sorts at one key. Reach for a group only on the entities that must stay welded; leave the scenery flat.

The world-ui example wraps each enemy’s body and floating crystal in a group, so the roaming player never slices an enemy down the middle.

LayerDef.isRenderGroup: true promotes the layer’s container to a Pixi v8 render group. Render groups render as a separate pass with their own instruction set and have their transforms processed on the GPU rather than the CPU, which can be useful for isolating large, slow-changing subtrees.

readonly layers: readonly LayerDef[] = [
{ name: "ground", order: -10 },
// Stable mid-scene actors — promote to a render group so transform
// updates above this layer don't re-walk its children every frame.
{ name: "actors", order: 0, isRenderGroup: true },
{ name: "ui", order: 100, space: "screen" },
];

Render groups carry a small fixed cost (their own render pass and instruction set), so flip the flag on only where you’ve measured a benefit. Default: false.