Skip to content

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.

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 },
snap: true,
});

smoothing controls how quickly the camera catches up (1 = instant, 0 = never moves). The deadzone defines a rectangle in the center of the screen where the target can move without the camera responding.

snap: true starts the camera on the target. Leave it out and a smoothed camera begins wherever it spawned — the world origin, unless position or fitTo places it elsewhere — so the scene opens with a visible glide toward the player. The offset applies to the snapped position, and any deadzone takes effect from the next frame.

snapToTarget() does the same cut mid-game, for a room change or a respawn where the target jumps across the map:

player.get(Transform).setPosition(1600, 900);
cam.snapToTarget();
camera.zoomTo(2.0, 0.5, easeOutQuad); // zoom to 2x over 0.5s
camera.rotation = Math.PI / 12; // tilt the camera
camera.shake(8, 0.4, { decay: 1 });

intensity is the maximum pixel displacement per frame. decay sets how much the shake fades across its duration: 0 (the default) shakes at full strength until it ends, 1 fades linearly to zero over the duration, and values above 1 reach zero earlier — at 2 the camera stops moving halfway through.

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 UISurface (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 { UISurface, 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 UISurface({
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.

Layers control which entities paint on top of which, and within a layer you can depth-sort entities so a top-down game’s “in front of” relationship looks right (a character standing below a sign paints over it, and one standing above it paints under it). See Layers & Draw Order for declaring layers, depth sorting with sort/ySort, and keeping a multi-part entity from splitting apart under a sort with SortGroupComponent.

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.