Camera
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:
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.
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:
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.
Following a Target
Section titled “Following a Target”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();Zoom and Rotation
Section titled “Zoom and Rotation”import { easeOutQuad } from "@yagejs/core";
camera.zoomTo(2.0, 0.5, easeOutQuad); // zoom to 2x over 0.5scamera.rotation = Math.PI / 12; // tilt the cameraScreen Shake
Section titled “Screen Shake”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.
Temporary camera modifiers
Section titled “Temporary camera modifiers”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.
Coordinate Conversion
Section titled “Coordinate Conversion”Convert between screen (pixel) space and world space:
const screenX = 120; // a point in viewport pixelsconst 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 updatescamera.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.
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 };The components behind the camera
Section titled “The components behind the camera”CameraEntity composes five components, and each convenience method above
delegates to one of them:
| Component | What it does |
|---|---|
CameraComponent | Holds position, rotation, zoom, the viewport size, the layer bindings, and the modifiers host. Every delegate on the entity forwards here. |
CameraFollow | Moves CameraComponent.position toward the follow target each frame, applying the smoothing, offset, and deadzone from the follow options. |
CameraBoundsComponent | Clamps 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. |
CameraShake | Contributes one removable displacement to CameraComponent.modifiers for a duration, leaving the base position where it is. |
CameraZoom | Interpolates 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.
Camera bindings
Section titled “Camera bindings”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.
Applying a camera to a custom container
Section titled “Applying a camera to a custom container”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 toolsyncCameraTransform(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 (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 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.
Render layers and draw order
Section titled “Render layers and draw order”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.
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.
import { Transform } from "@yagejs/core";
// Moving the transform moves the sprite on screenconst 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.