Creating Spelunky-style level transitions in Phaser
Tags: phaser, javascript, gamedev
Published: 2019-09-03 22:37:04

_My [last article](https://dev.to/jorbascrumps/loading-server-generated-tilemaps-with-phaser-4mm7) turned out to be a bit of a niche topic, so I decided to try my hand at something a little more mainstream. Though we'll still be discussing Phaser (gotta monopolize that niche!), you don't need to read the previous article to follow along with this one._

Today we're going to take a look at how we can implement Spelunky-inspired level transitions in Phaser. You can see the finished product in the [live demo](https://jorbascrumps.github.io/phaser-tutorial-spelunky-transitions/) and you can find the source code over on [Github](https://github.com/jorbascrumps/phaser-tutorial-spelunky-transitions). We'll start by reviewing the effect and learning a bit about scene events and transitions, then jumping in to the implementation.

---

## The concept
Before we get into the weeds, let's review the effect that we're looking to achieve. If you haven't played Spelunky before (you really should), I've included a video for reference:

{% youtube fooVdqf7ZZM %}
Each level starts with a completely blank, black screen,  which immediately reveals the entire screen using a pinhole transition. The transition doesn't start from the centre of the screen; instead, the transition is positioned on the player's character to centre your attention there. Exit transitions do the same in reverse &mdash; fillingthe screen with darkness around the player.

Let's dig into how we can replicate this effect.

_Update Nov 26, 2020 &mdash; Here is a preview of the final result:_

![Transition demo](https://dev-to-uploads.s3.amazonaws.com/i/etobesfudywux2ys1ybk.gif)
 

## Scene events
There are many events built into Phaser triggered during the lifecycle of a Scene that give you a lot of control. For example, if you're a plugin author you might use the `boot` event to hook into the boot sequence of a Scene; or, you may want to do some cleanup when your scene is destroyed or put to sleep. For our purposes, we'll be using the `create` event to know when our level is ready to be played.

You can listen to events from within your scene like this:
```js
this.events.on('create', fn);
```
I prefer to use the provided namespaced constants:
```js
this.events.on(Phaser.Scenes.Events.CREATE_EVENT, fn);
```

> Refer to the [documentation](https://photonstorm.github.io/phaser3-docs/Phaser.Scenes.Events.html#toc2__anchor) for more information on Scene events.

## Scene transitions
For this effect, we're going to use Scene transitions, which allow us to smoothly move from one scene to another. We can control exactly how this transition behaves by specifying a configuration object. If you've ever worked with tweens then you'll feel right at home as there are similarities between them.

Transitions can be started by invoking the Scene plugin:

```js
this.scene.transition({
    // Configuration options
});
```

Similar to Scene events, there are corresponding events for the transition lifecycle. These events can be subscribed to directly on the scene. We'll be using the `out` event to know when a transition is taking place.

```js
this.events.on(Phaser.Scenes.Events.TRANSITION_OUT, fn);
```

> The arguments for transition event handlers subtly change from event to event. Refer to the [documentation](https://photonstorm.github.io/phaser3-docs/Phaser.Scenes.Events.html#toc2__anchor) for details.

## Putting it all together
The first step is to create an empty base class. It is not strictly necessary to create a separate class but doing so will help isolate the code and make reusing it across levels easier. For now, just extend this bare scene; we'll flesh it out as we go along.

```js
class SceneTransition extends Phaser.Scene {
    // TODO
}

class LevelScene extends SceneTransition {}
```

### All your base (class)
Now that we have our classes in place, we can begin filling them out. Start by using the Graphics object to create a circle and centre it in the scene. The circle should be as large as possible while still being contained within the scene, otherwise the graphic will be cropped later on. This also helps minimize artifacts from appearing along the edges during scaling.

```js
const maskShape = new Phaser.Geom.Circle(
    this.sys.game.config.width / 2,
    this.sys.game.config.height / 2,
    this.sys.game.config.height / 2
);
const maskGfx = this.add.graphics()
    .setDefaultStyles({
        fillStyle: {
            color: 0xffffff,
        }
    })
    .fillCircleShape(maskShape)
;
```

You should end up with the following:

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/h6yfwbdz57l4u4a01lfi.png)

Next we're going to convert the mask graphic to a texture and add that to the scene as an image. We don't want the mask graphic itself to be visible in the final result, so make sure to remove the fill.

```js
// ...

const maskGfx = this.add.graphics()
    .fillCircleShape(maskShape)
    .generateTexture('mask')
;
this.mask = this.add.image(0, 0, 'mask')
    .setPosition(
        this.sys.game.config.width / 2,
        this.sys.game.config.height / 2,
    )
;
```

You should now be back to a blank scene. Finally, we apply the mask to the camera.

```js
this.cameras.main.setMask(
    new Phaser.Display.Masks.BitmapMask(this, this.mask)
);
```

### Creating the level
We're not going to spend much time setting up the level itself. The only requirement is that you extend the base class we created and include a key. Get creative!

```js
import SceneTransition from './SceneTransition';

export default class LevelOne extends SceneTransition {

    constructor () {
        super({
            key: 'ONE',
        });
    }

    preload () {
        this.load.image('background_one', 'https://labs.phaser.io/assets/demoscene/birdy-nam-nam-bg1.png');
    }

    create() {
        super.create();

        this.add.image(0, 0, 'background_one')
            .setOrigin(0, 0)
            .setDisplaySize(
                this.sys.game.config.width,
                this.sys.game.config.height
            )
        ;
    }

}
```

You should now see something similar to this:

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/pjjpvvkxi03vycop9dla.png)

### Setting up the events
Returning to the base class, we need to record two values. The first will be the minimum scale that the mask will be; the second is the maximum.

```js
const MASK_MIN_SCALE = 0;
const MASK_MAX_SCALE = 2;
```

The minimum value is fairly straightforward: to create a seamless transition, we need the mask to shrink completely. The maximum is a little more tricky and will depend on the aspect ratio of your game and what shape you use for your mask. Play around with this value until you're confident it does the job. In my case, my mask needs to be twice its initial scale to completely clear the outside of the scene.

Next we can (finally) leverage those events from earlier. When a transition is started, we want to animate the mask from its maximum scale to its minimum. It would also be a nice touch to have the action paused to prevent enemies from attacking the player, so let's add that in.

```js
this.events.on(Phaser.Scenes.Events.TRANSITION_OUT, () => {
    this.scene.pause();

    const propertyConfig = {
        ease: 'Expo.easeInOut',
        from: MASK_MAX_SCALE,
        start: MASK_MAX_SCALE,
        to: MASK_MIN_SCALE,
    };

    this.tweens.add({
        duration: 2500,
        scaleX: propertyConfig,
        scaleY: propertyConfig,
        targets: this.mask,
    });
});
```

Once the next scene is ready we want to run the animation in reverse to complete the loop. There are a few changes between this animation and the last that are worth discussing, primarily around timing. The first change is the duration of the animation; it has been roughly halved in order to get the player back into the action faster. You may have also noticed the addition of the `delay` property. In my testing, I found that the animation can look a bit off if it reverses too quickly. So a small pause has been added to create a sense of anticipation.

```js
this.events.on(Phaser.Scenes.Events.CREATE, () => {
    const propertyConfig = {
        ease: 'Expo.easeInOut',
        from: MASK_MIN_SCALE,
        start: MASK_MIN_SCALE,
        to: MASK_MAX_SCALE,
    };

    this.tweens.add({
        delay: 2750,
        duration: 1500,
        scaleX: propertyConfig,
        scaleY: propertyConfig,
        targets: this.mask,
    });
});
```

### Triggering a transition
So far we have very little to show for all of this setup that we've done. Let's add a trigger to start a transition. Here we're using a pointer event in our level but this could be triggered by anything in your game (e.g., collision with a tile, the result of a timer counting down, etc.).

```js
this.input.on('pointerdown', () => {
    this.scene.transition({
        duration: 2500,
        target: 'ONE',
    });
});
```

If you tried to trigger the transition, you may have noticed that nothing happens. This is because you cannot transition to a scene from itself. For the sake of this example, you can duplicate your level (be sure to give it a unique key) and then transition to that.

And that's it! You should now have your very own Spelunky-inspired level transition.

## Conclusion
Level transitions are a great way to add a level of immersion and polish to your game that doesn't take a whole lot of effort. Since the effect is entirely created by applying a mask to the Camera, it could easily be modified to use, say, Mario's head to replicate the effect found in _New Super Mario Bros._ Or if you're feeling more adventurous (and less copyright-infringy) you could create a wholly unique sequence with subtle animation flourishes. The only limit really _is_ your imagination.

---

Thank you for taking the time to join me on this adventure! I've been having a lot of fun working on these articles and I hope they come in handy for someone. If you end up using this technique in one of your games or just want to let me know what you think, leave a comment here or hit me up on [Twitter](https://twitter.com/jorbascrumps).