Scene Transitions
Scene transitions animate the visual handoff when pushing, popping, or replacing scenes. During a transition, both scenes coexist on the stack so you can crossfade, flash, or run any custom animation.
Quick Start
Section titled “Quick Start”import { fade } from "@yagejs/renderer";
// Push with a fade to blackawait engine.scenes.push(new GameScene(), { transition: fade({ duration: 400 }),});
// Pop with a fadeawait engine.scenes.pop({ transition: fade({ duration: 300 }),});Built-in Transitions
Section titled “Built-in Transitions”Renderer (visual)
Section titled “Renderer (visual)”fade({ duration?, color?, coverScreen? }) — Fades to a solid color and
back. The first half fades out, the second half fades in. Defaults to 300ms,
black, play-area-only. Pass coverScreen: true to also obscure letterbox bars
(see the caution block below).
import { fade } from "@yagejs/renderer";
fade() // 300ms blackfade({ duration: 500, color: 0x1a1a2e }) // 500ms dark bluefade({ coverScreen: true }) // also obscure the letterbox barsflash({ duration?, color?, coverScreen? }) — Flashes a solid color that
decays from full opacity to zero. Great for impacts or screen-clearing
effects. Defaults to 200ms, white, play-area-only.
import { flash } from "@yagejs/renderer";
flash() // 200ms whiteflash({ duration: 150, color: 0xff0000 }) // quick red flashflash({ coverScreen: true }) // also obscure the letterbox barscrossFade({ duration? }) — Cross-dissolves between scenes. The
outgoing scene fades 1→0 while the incoming scene fades 0→1. Both stay
visible throughout — no blackout in the middle. Defaults to 400ms.
import { crossFade } from "@yagejs/renderer";
crossFade() // 400mscrossFade({ duration: 600 })iris({ duration?, color?, center?, coverScreen? }) — Closing iris →
swap → opening iris. A circular cut-out of the screen shrinks to zero
over the first half (covering everything in color), then grows back
over the second half to reveal the destination. Symmetric to fade()
but with a circular shape — perfect for retro-style transitions like
Zelda’s overworld → cave or Mario’s level intros. Defaults to 600ms,
black, virtual-center, play-area-only.
import { iris } from "@yagejs/renderer";
iris() // 600ms black iris from the virtual-space centeriris({ duration: 900, color: 0x111111, center: { x: 64, y: 64 } })iris({ coverScreen: true }) // also obscure the letterbox barsiris.center and irisReveal.center are both in virtual pixels —
the same coords your game logic uses. coverScreen: true re-parents the
overlay to app.stage so it covers the canvas including letterbox /
expand bars; the center is converted internally. See the caution block
below for the underlying rule.
irisReveal({ duration?, center?, easing? }) — One-way iris that
masks the incoming scene’s container with an expanding circle, so the
new scene “blooms” over the previous one from the chosen point. No
color overlay and no dip-to-black mid-point — the previous scene stays
visible outside the circle until the iris covers it. Reach for
irisReveal when you want a smooth reveal; reach for iris when you
want the retro hard-cut feel.
import { irisReveal } from "@yagejs/renderer";
irisReveal() // 600ms reveal from the centeririsReveal({ duration: 800, center: { x: 0, y: 0 }, // bloom from the top-left (virtual px) easing: (t) => 1 - Math.pow(1 - t, 3),})chessboard({ duration?, rows?, cols? }) — Staggered checkerboard
mask painted directly onto the incoming scene’s container. Even-parity
cells grow over [0, 0.7] of the duration, odd-parity over [0.3, 1]
(0.4-wide overlap, smoothstep-eased), so the new scene “paints in”
cell-by-cell on top of the previous one — no blackout, no color overlay.
Defaults to 700ms and a 6×10 grid.
import { chessboard } from "@yagejs/renderer";
chessboard() // 700ms 6×10 gridchessboard({ rows: 4, cols: 6, duration: 900 })slidePush({ duration?, direction?, reverseOnPop?, easing? }) — Both
scenes translate in lockstep, so the incoming scene visually pushes the
outgoing one off the opposite edge. direction is the outgoing scene’s
exit direction ("left" | "right" | "up" | "down", default "left").
On pop the direction is mirrored automatically so back motion reverses
forward motion — opt out with reverseOnPop: false. Defaults to 500ms,
cubic ease-out.
import { slidePush } from "@yagejs/renderer";
slidePush() // 500ms left pushslidePush({ direction: "up", duration: 400 })slidePush({ direction: "right", reverseOnPop: false, // pop also slides right easing: (t) => t * t,})@yagejs/core ships the SceneTransition contract and orchestration but
no concrete transitions — all built-ins live in @yagejs/renderer because
they need PIXI. For multi-step sequences (delayed fades, strobing flashes,
chained effects) write a custom transition against the contract; the
built-ins each manage their own scene visibility, so chaining them is
usually not what you want.
Per-Scene Defaults
Section titled “Per-Scene Defaults”Set defaultTransition on a scene class to use it automatically when no
call-site transition is provided:
class MenuScene extends Scene { readonly name = "menu"; readonly defaultTransition = fade({ duration: 300 });
onEnter() { /* ... */ }}
// Uses MenuScene.defaultTransition automaticallyawait engine.scenes.push(new MenuScene());
// Override with a call-site transitionawait engine.scenes.push(new MenuScene(), { transition: flash({ duration: 200 }),});Checking Transition State
Section titled “Checking Transition State”// On the scene managerif (engine.scenes.isTransitioning) { // don't accept input during transitions}
// On any sceneif (scene.isTransitioning) { return;}Events
Section titled “Events”The engine event bus emits events when transitions start and end:
engine.events.on("scene:transition:started", ({ kind }) => { console.log(`Transition started: ${kind}`); // "push", "pop", or "replace"});
engine.events.on("scene:transition:ended", ({ kind }) => { console.log(`Transition ended: ${kind}`);});Custom Transitions
Section titled “Custom Transitions”Implement the SceneTransition interface to create your own. Two helpers
from @yagejs/renderer cover most cases:
getSceneContainer(ctx, scene)— returns the PIXIContainerfor a scene so you can manipulate itsalpha,visible,position, orfiltersdirectly.getVirtualBounds(ctx)— returns{ width, height }of the scene-root coordinate space. Use it to size masks, translations, or geometry parented to a scene root (or any descendant of_worldRoot, which carries the responsive-fit transform).
import type { SceneTransition, SceneTransitionContext } from "@yagejs/core";import type { Container } from "pixi.js";import { getSceneContainer, getVirtualBounds } from "@yagejs/renderer";
function slideIn(duration: number): SceneTransition { let toRoot: Container | undefined; let width = 0; return { duration, begin(ctx: SceneTransitionContext) { toRoot = getSceneContainer(ctx, ctx.toScene); width = getVirtualBounds(ctx).width; if (toRoot) toRoot.x = width; }, tick(_dt: number, ctx: SceneTransitionContext) { if (!toRoot) return; const t = Math.min(ctx.elapsed / duration, 1); const eased = 1 - Math.pow(1 - t, 3); toRoot.x = width * (1 - eased); }, end() { if (toRoot) toRoot.x = 0; toRoot = undefined; }, };}The SceneTransitionContext gives you:
elapsed— wall-clock ms sincebegin()kind—"push","pop", or"replace"engineContext— DI container for resolving servicesfromScene/toScene— the scenes involved
Composition with Loading Scenes
Section titled “Composition with Loading Scenes”LoadingScene carries its own transition — the
one used when it hands off to its target scene. It composes cleanly with a
call-site transition passed to push/replace:
await engine.scenes.replace(new Boot(), { transition: fade({ duration: 400 }), // mount Boot with this fade});// Boot.transition animates the handoff Boot → target separately.Lifecycle Details
Section titled “Lifecycle Details”How replace Works
Section titled “How replace Works”When replacing with a transition:
- The new scene is pushed first (
onEnterfires) transition.begin()runs synchronously after mount/onEnterand before the first frame is rendered — safe to reachtoScene’s container here- Both scenes coexist during the transition (
tickper frame) transition.end()fires (can still reference both scenes)- The old scene is removed (
onExit, entity destruction) scene:replacedevent emitted
Queueing
Section titled “Queueing”Multiple push/pop/replace/popAll calls queue automatically — they
don’t cancel each other. Each completes its transition before the next
starts.
engine.scenes.popAll() is also queued: it waits for any in-flight
transition and pending ops to finish before tearing the stack down. Use it
for “restart from menu”-style flows — not as an emergency abort.