Skip to content

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.

import { fade } from "@yagejs/renderer";
// Push with a fade to black
await engine.scenes.push(new GameScene(), {
transition: fade({ duration: 400 }),
});
// Pop with a fade
await engine.scenes.pop({
transition: fade({ duration: 300 }),
});

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 black
fade({ duration: 500, color: 0x1a1a2e }) // 500ms dark blue
fade({ coverScreen: true }) // also obscure the letterbox bars

flash({ 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 white
flash({ duration: 150, color: 0xff0000 }) // quick red flash
flash({ coverScreen: true }) // also obscure the letterbox bars

crossFade({ 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() // 400ms
crossFade({ 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 center
iris({ duration: 900, color: 0x111111, center: { x: 64, y: 64 } })
iris({ coverScreen: true }) // also obscure the letterbox bars

iris.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 center
irisReveal({
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 grid
chessboard({ 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 push
slidePush({ 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.

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 automatically
await engine.scenes.push(new MenuScene());
// Override with a call-site transition
await engine.scenes.push(new MenuScene(), {
transition: flash({ duration: 200 }),
});
// On the scene manager
if (engine.scenes.isTransitioning) {
// don't accept input during transitions
}
// On any scene
if (scene.isTransitioning) {
return;
}

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}`);
});

Implement the SceneTransition interface to create your own. Two helpers from @yagejs/renderer cover most cases:

  • getSceneContainer(ctx, scene) — returns the PIXI Container for a scene so you can manipulate its alpha, visible, position, or filters directly.
  • 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 since begin()
  • kind"push", "pop", or "replace"
  • engineContext — DI container for resolving services
  • fromScene / toScene — the scenes involved

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.

When replacing with a transition:

  1. The new scene is pushed first (onEnter fires)
  2. transition.begin() runs synchronously after mount/onEnter and before the first frame is rendered — safe to reach toScene’s container here
  3. Both scenes coexist during the transition (tick per frame)
  4. transition.end() fires (can still reference both scenes)
  5. The old scene is removed (onExit, entity destruction)
  6. scene:replaced event emitted

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.