Skip to content

Camera & Layers

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.

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.

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.

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.

camera.zoomTo(2.0, 500, easeOutQuad); // zoom to 2x over 500ms
camera.rotation = Math.PI / 12; // tilt the camera
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.

Convert between screen (pixel) space and world space:

const worldPos = camera.screenToWorld(screenPos);
const screenPos = camera.worldToScreen(worldPos);

Constrain the camera to a region so it never shows areas outside the level:

camera.bounds = { minX: 0, minY: 0, maxX: 4000, maxY: 2000 };

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 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 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. Entities within the same layer are sorted by their y-position by default.

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 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.

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.

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.

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.

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 screen
entity.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.