Camera & Layers
Camera
Section titled “Camera”The camera controls the viewport into your game world. Spawn a CameraEntity
in your scene to create it:
import { Vec2 } from "@yagejs/core";import { CameraEntity } from "@yagejs/renderer";
const camera = this.spawn(CameraEntity, { position: new Vec2(400, 300) });// All camera operations are available directly on camera:// camera.follow(), camera.shake(), camera.zoomTo(), camera.bounds, etc.Coordinate Convention
Section titled “Coordinate Convention”Camera position (0, 0) places the world origin at the center of the
viewport, not the top-left. An entity drawn at world position (0, 0)
appears in the middle of the screen; positive X goes right, positive Y goes
down.
This is the convention most camera-driven 2D games expect. A scrolling shooter or platformer naturally wants the camera to follow the player, and centring the follow target on screen is the intuitive default.
If your game has a fixed, non-scrolling layout (a puzzle grid, an arcade-style
single-screen game, a tile editor), you probably want world (0, 0) to align
with the top-left of the screen instead — that way tile coordinates, UI
anchors, and typical 2D art tools line up the way you’d expect. Offset the
camera by half the viewport in onEnter:
class GameScene extends Scene { readonly name = "game";
onEnter() { // Top-left origin: world (0,0) → screen (0,0) this.spawn(CameraEntity, { position: new Vec2(400, 300) }); // viewport is 800×600 }}The camera never changes; you’re just choosing which world point sits under the viewport’s top-left corner. Follow-a-target cameras work identically with either convention — they just move to frame the target.
Frame a world rectangle (fitTo)
Section titled “Frame a world rectangle (fitTo)”For fixed-camera scenes — puzzle boards, arcade-style single-screen
games, dialog-scene insets — the ergonomic gap is “frame this
rectangle”: position and zoom together so a known world area fills
the viewport. fitTo: { x, y, width, height } does both in one call:
this.spawn(CameraEntity, { fitTo: { x: 0, y: 0, width: 800, height: 600 },});The camera centres on the rect’s midpoint and sets zoom = min(viewportW / rect.w, viewportH / rect.h) — contain semantics, so the entire rect is
always visible (any spare viewport space along the off-aspect axis stays
empty, exactly like CSS object-fit: contain). Overrides any explicit
position and zoom you pass alongside.
fitTo is applied once at setup against the renderer’s current
viewport. For runtime re-framing (entering a sub-area, transition
between rooms), set position and call zoomTo() directly on the
camera — fitTo isn’t a responsive binding.
Want a fixed camera that doesn’t auto-track? Just don’t pass follow —
that’s already the “don’t move” state. The previous fit: "static"
mode was redundant with that and has been dropped.
Following a Target
Section titled “Following a Target”const cam = this.spawn(CameraEntity, { follow: player.get(Transform), smoothing: 0.1, offset: { x: 0, y: -50 }, deadzone: { halfWidth: 50, halfHeight: 30 },});smoothing controls how quickly the camera catches up (0 = instant, 1 = never
moves). The deadzone defines a rectangle in the center of the screen where
the target can move without the camera responding.
Zoom and Rotation
Section titled “Zoom and Rotation”camera.zoomTo(2.0, 500, easeOutQuad); // zoom to 2x over 500mscamera.rotation = Math.PI / 12; // tilt the cameraScreen Shake
Section titled “Screen Shake”camera.shake(8, 400, { decay: true });intensity is the maximum pixel displacement per frame. When decay is true,
the shake fades out over the duration.
Coordinate Conversion
Section titled “Coordinate Conversion”Convert between screen (pixel) space and world space:
const worldPos = camera.screenToWorld(screenPos);const screenPos = camera.worldToScreen(worldPos);Bounds
Section titled “Bounds”Constrain the camera to a region so it never shows areas outside the level:
camera.bounds = { minX: 0, minY: 0, maxX: 4000, maxY: 2000 };Camera bindings
Section titled “Camera bindings”A CameraEntity spawned without bindings auto-binds every
space: "world" layer at full strength. For finer control — parallax,
minimaps, decoupled HUDs — pass an explicit bindings array. Each
binding has three independent ratios:
interface CameraBinding { layer: string; translateRatio?: number; // default 1 — follows the camera position rotateRatio?: number; // default 1 — rotates with the camera scaleRatio?: number; // default 1 — zooms with the camera}Each ratio is a linear blend from identity (0, ignores that axis of the
camera) to full effect (1, fully follows that axis).
this.spawn(CameraEntity, { bindings: [ { layer: "sky", translateRatio: 0.1 }, // slow parallax { layer: "mid", translateRatio: 0.6 }, // medium parallax { layer: "world" }, // full transform (default) { layer: "minimap", // camera-agnostic overlay painted on a world layer translateRatio: 0, rotateRatio: 0, scaleRatio: 0 }, ],});These ratios are layer-level decoupling primitives: they’re the
right answer for parallax, minimaps, and other content whose position
already lives in the coord space the layer provides. They are not
the right answer for entity-anchored UI like nameplates or health bars
— mixing a partial camera transform with the main camera’s full
transform separates the UI from its target under zoom. For that use
case, see ScreenFollow below.
ScreenFollow (entity-anchored UI)
Section titled “ScreenFollow (entity-anchored UI)”ScreenFollow projects a world source through a camera and writes the
resulting screen coord to its entity’s Transform each frame. Paired
with a UIPanel (or UIRoot) on a screen-space layer using
positioning: "transform", it produces UI that tracks a target entity
but stays axis-aligned and constant-size regardless of camera zoom or
rotation — the canonical “billboard” primitive for nameplates, health
bars, damage numbers, and interaction prompts.
import { ScreenFollow } from "@yagejs/renderer";import { UIPanel, Anchor } from "@yagejs/ui";
class EnemyNameplate extends Entity { setup(params: { target: Entity; camera: CameraEntity; label: string }) { this.add(new Transform()); this.add(new ScreenFollow({ target: params.target, camera: params.camera, offset: new Vec2(0, -40), // 40 screen pixels above the target, at any zoom })); const panel = this.add(new UIPanel({ positioning: "transform", // read Transform.worldPosition anchor: Anchor.BottomCenter, // pivot on the panel padding: 4, background: { color: 0x000000, alpha: 0.6, radius: 4 }, })); panel.text(params.label, { fontSize: 11, fill: 0xffffff }); }}offset is applied in screen pixels, after projection —
concretely cam.worldToScreen(target) + offset. That keeps the visual
gap between UI and target fixed under any camera transform: a 40px
offset above is 40 screen pixels above at any zoom, any rotation. Adding
the offset in world coords before projection (the intuitive-seeming
shape) would let the camera transform warp it — the gap would double at
zoom 2 and rotate off-axis as the camera rotates.
target accepts an Entity, a static Vec2Like, or a function
returning a Vec2Like — you can track anything whose world position
you can name, including animated paths or the midpoint of two entities.
See the UI guide for
the full logical-root + siblings pattern and the world-ui example for
a runnable demo.
Render Layers
Section titled “Render Layers”Render layers control draw order. Entities on higher layers render on top.
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).
Layer space
Section titled “Layer space”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. ACameraEntityspawned without explicitbindingsskips screen-space layers on auto-bind; you can still bind one explicitly by naming it inbindings.
If no "ui" layer is declared, @yagejs/ui auto-provisions one as
space: "screen" the first time a UIPanel is added, so HUDs just
work without any layer wiring.
See the UI guide for how
UIPanel/UIRoot pick between viewport-anchored and Transform-pinned
positioning.
Per-frame paint order with sort
Section titled “Per-frame paint order with sort”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:
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.
Sorting the default layer
Section titled “Sorting the default layer”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 Componenttree.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.
Manual zIndex still works alongside sort
Section titled “Manual zIndex still works alongside sort”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/ySortByread 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 itsTransformposition, 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. PassinnerSort: ySortif 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. ASortGroupComponenton 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.
Pixi render groups with isRenderGroup
Section titled “Pixi render groups with isRenderGroup”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.
Display System
Section titled “Display System”The built-in display system automatically synchronizes each entity’s Transform
component with the underlying PixiJS display object. When you update position,
rotation, or scale on a Transform, the corresponding Pixi sprite or graphic
moves to match — no manual syncing required.
// Moving the transform moves the sprite on screenentity.transform.setPosition(200, 300);entity.transform.rotate(0.5);entity.transform.setScale(2, 2);This one-way sync (ECS to Pixi) runs once per frame after all component updates have completed, keeping rendering deterministic and free of mid-frame visual glitches.