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:

import { Scene, Vec2 } from "@yagejs/core";
import { CameraEntity } from "@yagejs/renderer";
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:

import { CameraEntity } from "@yagejs/renderer";
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.

import { Entity, Transform } from "@yagejs/core";
import { CameraEntity } from "@yagejs/renderer";
class Player extends Entity {
setup() {
this.add(new Transform());
}
}
const player = this.spawn(Player);
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 is instant, and 0 never moves the camera at all, so the follow looks frozen. offset and deadzone are both in world pixels; the deadzone is a rectangle centred on the camera 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();
import { easeOutQuad } from "@yagejs/core";
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 displacement per axis in world pixels, so what the player sees scales with zoom — the same intensity moves twice as far on screen at zoom 2. 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.

Use camera.modifiers when several temporary camera effects need independent ownership:

const impact = camera.modifiers.add({
position: { x: 4, y: 0 },
rotation: 0.02,
zoom: 1.05,
});
impact.setPosition({ x: -2, y: 1 });
impact.remove();

Position and rotation contributions add. Zoom contributions multiply. The camera keeps its base position, rotation, and zoom; rendered layers and coordinate conversion use effectivePosition, effectiveRotation, and effectiveZoom. Each handle removes only its own contribution. Modifiers are transient and are not saved. Built-in camera.shake() also contributes through this host, so it composes with other modifiers.

Convert between screen (pixel) space and world space:

const screenX = 120; // a point in viewport pixels
const screenY = 80;
const worldPos = camera.screenToWorld(screenX, screenY);
const screenPos = camera.worldToScreen(worldPos.x, worldPos.y);

Both take two numbers and return a Vec2. They use the camera’s own transform, not any layer’s — a layer bound with a ratio below 1 (parallax, a dampened-zoom layer) renders under a different transform, so the result does not name a point on that layer.

For repeated queries, keep a Vec2Buffer and pass it to the corresponding Into method:

import { Vec2Buffer } from "@yagejs/core";
const point = new Vec2Buffer(); // keep between updates
camera.screenToWorldInto(point, screenX, screenY);
camera.worldToScreenInto(point, point.x, point.y);
camera.getEffectivePositionInto(point);

These methods are available on both CameraEntity and CameraComponent. Each overwrites and returns the same buffer without constructing a Vec2. The results include active camera modifiers. Screen coordinates use virtual viewport pixels, and world coordinates use world pixels. A buffer keeps the last query’s coordinates until you overwrite it; use the ordinary immutable queries for values you want to keep or share.

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

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

CameraEntity composes five components, and each convenience method above delegates to one of them:

ComponentWhat it does
CameraComponentHolds position, rotation, zoom, the viewport size, the layer bindings, and the modifiers host. Every delegate on the entity forwards here.
CameraFollowMoves CameraComponent.position toward the follow target each frame, applying the smoothing, offset, and deadzone from the follow options.
CameraBoundsComponentClamps CameraComponent.position into the rectangle camera.bounds names. It runs after the behaviours that move or zoom the camera, so the clamp reads this frame’s position and zoom.
CameraShakeContributes one removable displacement to CameraComponent.modifiers for a duration, leaving the base position where it is.
CameraZoomInterpolates CameraComponent.zoom toward a target over a duration, through an easing function.

camera.follow(), camera.snapToTarget(), camera.bounds, camera.shake(), and camera.zoomTo() cover what a game usually needs. Reach a behaviour component itself with camera.get(...) to read state that has no delegate — CameraShake.offset is the displacement the shake contributes this frame, which camera.position does not include:

import { CameraShake } from "@yagejs/renderer";
const displacement = camera.get(CameraShake).offset;

All five are exported from @yagejs/renderer, so a hand-built camera entity can carry exactly the behaviours it uses.

A CameraEntity auto-binds every space: "world" layer at full strength, including layers created after it spawned. For finer control — parallax, minimaps, decoupled HUDs — pass a bindings array. Each entry replaces the auto-binding for the layer it names and 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).

import { CameraEntity } from "@yagejs/renderer";
this.spawn(CameraEntity, {
bindings: [
{ layer: "sky", translateRatio: 0.1 }, // slow parallax
{ layer: "mid", translateRatio: 0.6 }, // medium parallax
],
});

Every other world layer — "world", and any world-space layer a plugin or a later ensureLayer call adds — keeps following at full strength. An entry naming a space: "screen" layer adds it to the set.

Pass autoBind: false to drop the auto-bound set, so the camera drives exactly the layers you name:

import { CameraEntity } from "@yagejs/renderer";
// Drives "sky", "mid" and "world"; the world-space "minimap" layer,
// left out of the list, stays untransformed.
this.spawn(CameraEntity, {
autoBind: false,
bindings: [
{ layer: "sky", translateRatio: 0.1 },
{ layer: "mid", translateRatio: 0.6 },
{ layer: "world" },
],
});

That is the only way to leave a world-space layer untransformed. A binding with all three ratios at 0 is a different result: the layer stops following the camera but its origin sits at the viewport centre.

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.

syncCameraTransform positions a custom display container through a camera, including its active modifiers. Use it when writing a rendering tool that owns its own container:

import { Container } from "pixi.js";
import { CameraComponent, syncCameraTransform } from "@yagejs/renderer";
const container = new Container(); // owned by your rendering tool
syncCameraTransform(container, camera.get(CameraComponent));

The signature is syncCameraTransform(target: DisplayContainer, camera?: CameraComponent, binding?: CameraBinding): void. The optional binding applies the translation, rotation and scale ratios described above; omitted ratios use 1. Without a camera, the helper resets the target to position (0, 0), scale (1, 1) and rotation 0.

The helper changes only the container’s transform. It does not attach the container to a layer or apply responsive fit scaling. Ordinary scene layers receive the same camera calculation automatically from DisplaySystem.

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 { Entity, Transform, Vec2 } from "@yagejs/core";
import { ScreenFollow, type CameraEntity } 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.

import { Transform } from "@yagejs/core";
// Moving the transform moves the sprite on screen
const transform = entity.get(Transform);
transform.setPosition(200, 300);
transform.rotate(0.5);
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.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/renderer.md.