Writing a transition
A transition is whatever the player sees between travel() and the moment the new level is playable: a bar, a splash with a tip, a short scene to walk around in. The game writes it; the engine supplies the moment (when it starts, how far the target has got, when it may end) and the safety around it. These are examples to copy from, not stock content - Ferret ships only bar, the loading screen of @ferret/ui.
The contract#
A Transition has five methods:
| Method | When | What it does |
|---|---|---|
enter(ctx) | the travel starts, in the same frame as travel() | show something at once; resolve when it is on screen |
update(p) | many times a second while the target downloads | p is a TransitionProgress: loaded, total, files, rate, eta, phase, in honest bytes |
ready(ctx) | the target is ready | resolve when the game wants to leave: a button, a timer, an animation |
fail(ctx, err) | the target failed or the player cancelled | return true when the transition shows the failure itself (Retry / Back) |
dispose() | after the player is let in | remove everything the transition made |
The TransitionContext says where the player is going and what is available: from, to, plan (what will be downloaded; plan.cached means a short wait), world, events, ui, persistent (the player and what travels with them), host (the scene host, below) and signal (the player cancelled).
Register by name on the level service and choose per travel:
levels.transitions.register('splash', () => new Splash()); // a function makes a fresh one per travel
levels.transitions.register('default', () => new Splash()); // used when nothing else is chosen
levels.transitions.choose((from, to, plan) => plan.cached ? undefined : to.tags?.includes('night') ? 'interlude' : 'splash');
await levels.travel('castle-gate', { transition: 'interlude' }); // or name one in the call
A name that is not registered falls back to the bar, and the chooser may throw without harm.
The safety around it#
Principle: a broken transition never blocks a travel.
enterhas 2 s to be on screen. After that the engine's bar takes over.- A throw in
enter,update,ready,failordisposeis logged and the bar takes over; every further call to the broken transition is skipped. - A cancel by the player does not keep them in a transition that waits for a button:
readyis raced againstsignal. - The player is let in only when the target is ready and
ready()has resolved.
Worked example: a splash#
A picture, a tip and a progress line. It uses plain DOM so it has no dependencies; a game with @ferret/ui would use that.
class Splash implements Transition {
private root = document.createElement('div');
private line = document.createElement('p');
private pressed!: () => void;
enter(ctx: TransitionContext) {
this.root.style.cssText = 'position:fixed;inset:0;display:grid;place-items:center;background:#111;color:#eee;z-index:50';
this.root.innerHTML = '<div><h1></h1></div>';
this.root.querySelector('h1')!.textContent = ctx.to.name ?? ctx.to.id;
this.root.firstElementChild!.append(this.line);
document.body.append(this.root);
}
update(p: TransitionProgress) {
const mb = (n: number) => (n / 1048576).toFixed(1);
this.line.textContent = p.phase === 'fetch' ? `${mb(p.loaded)} / ${mb(p.total)} MB` : p.phase;
}
ready() {
this.line.textContent = 'Press A';
return new Promise<void>(res => { this.pressed = res; this.root.onclick = () => res(); }); // the game decides when to leave
}
fail(ctx: TransitionContext, err: StreamError) {
this.line.textContent = `Could not load (${err.reason}). Press B to go back.`;
return true; // this transition shows the failure itself
}
dispose() { this.root.remove(); }
}
Keep the transition's own assets small and local. To have them on the device before the first travel, store.pinPack('transitions', [{ ref: 'splash.png', sha256 }]) downloads them once and keeps them.
Worked example: an interlude world#
An interlude is a short scene of its own - a lift ride, a corridor - shown while the target loads. It needs a second world, because the level's world is being torn down and rebuilt. Add a SceneHost to the game (game.host when created with createGame); it owns a second World and Scene on the same engine and renders only the active one.
class Lift implements Transition {
private world!: World;
enter(ctx: TransitionContext) {
this.world = ctx.host!.create('interlude'); // a World and Scene of its own
const cam = new BABYLON.FreeCamera('lift-cam', new BABYLON.Vector3(0, 1.6, -3), this.world.scene);
cam.setTarget(BABYLON.Vector3.Zero());
BABYLON.MeshBuilder.CreateBox('car', { width: 3, height: 3, depth: 3, sideOrientation: BABYLON.Mesh.BACKSIDE }, this.world.scene);
new BABYLON.HemisphericLight('l', new BABYLON.Vector3(0, 1, 0), this.world.scene);
ctx.host!.show(this.world); // the screen shows the lift from now on
}
update() {}
ready() { return new Promise<void>(res => setTimeout(res, 1500)); } // let the ride finish, then go
fail() { return false; } // the engine's failure screen
dispose() { if (this.world) this.host.dispose(this.world); }
constructor(private host: SceneHost) {}
}
levels.transitions.register('interlude', () => new Lift(game.host));
What the engine does for you: it shows the level's world again when the player is let in (and when a travel fails, is cancelled, or the transition breaks and falls back to the bar), pauses the level's world meanwhile, and net stops publishing while travel:transit is on. Disposing the interlude is the transition's job, in dispose.
Guidance for interludes (measured by the tools, not enforced): at most 12 MB of assets, 40 draw calls, 30 FPS on the console tier.
Trying it without a download#
travel.transitions -> ["bar", "splash", "interlude"]
travel.preview {name: "splash", seconds: 3, bytes: 5000000}
travel.preview {name: "splash", seconds: 2, fail: true}
travel.preview plays a transition against a simulated download - progress, ready, or a failure - and reports how it ended (ready, fail, whether it fell back to the bar), so a transition can be built and checked in the viewer without a level to travel to.
See also Streaming and catalogs for where the progress numbers come from.