Phaser 3 and Tiled: Building a Platformer

Introduction

Phaser 3 enables us to quickly create games in our browser with JavaScript. Some of our favorite 2D games are platformers - think of games like Mario, Sonic, Super Meat Boy, or Cuphead.

Tiled is a 2D map editor that's used to create game worlds. We'll explore how to create a platformer level with Tiled, integrate it with Phaser, and animate sprites to create a rich 2D platforming experience.

In this article we'll be creating a basic platformer game, where our player can move on jump in our world. If the player hits a spike, then we reset the position of the player. A playable demo of this game can be found here.

This tutorial is written for those familiar with Phaser 3. If you're not, get acquainted with the framework with one of our previous articles on Phaser.

Getting Started

To better follow along with this tutorial, download and unzip the project stackabuse-platformer.zip into your workspace. The folder should include the following assets:

  • index.html: Loads Phaser 3.17 and our game.js file
  • game.js: Contains the logic of our game
  • assets/images:
    • background.png
    • kenney_player.png
    • kenney_player_atlas.json
    • spike.png
  • assets/tilemaps: Empty folder, will be used to save Tiled files
  • assets/tilesets:
    • platformPack_tilesheet.png

Note: If you'd prefer, you can also follow along by viewing the code for the project on our GitHub repo.

Don't forget to run a server in your project folder, with your IDE or even with Python: python3 -m http.server. This is required for Phaser to be able to load these assets via HTTP. Again, for more info see our previous article on the topic (linked above).

All game assets were created and shared by Kenney. The atlas file was created with Atlas Phaser Packer.

Tiled Map Editor

Tiled is free and open source software to create game levels. It's available on all major desktop operating systems, so visit the website and download it to continue.

Creating a Tilemap

Open Tiled and click on "New Map". In the prompt, change the Tile layer format to "Base64 (uncompressed)", the width to 14 tiles and height to 7, and the Tile size to 64px each.

New Map

Save the file as "level1.tmx" in "assets/tilemaps".

Creating a Tileset

In the right pane, click "New Tileset...". In the popup, name the tileset "kenny_simple_platformer". Ensure that the "Embed in map" option is selected. Without that option, Phaser may experience problems loading your map correctly. In the "Source" property, select "platformPack_tilesheet.png" from the "assets/tilesets" directory.

The tilesheet's image width is 896px and height is 448px. It contains 98 images in total of equal size, they all fit into 7 rows and 14 columns. With basic maths we can deduce that each tile is 64px in width and height. Ensure that the tileset's width and height is 64px:

New Tileset

Designing our Level

Maps in Tiled are composed of layers. Each layer stores some design of the game world. Layers that are on top have their tiles shown over layers that are below. We get depth by using them. This basic game will have only two layers:

  • Platform: contains the world the player interacts with
  • Spikes: contains the dangerous spikes that can hurt the player.

The Platform Layer

Before we add our tiles to the map, let's first rename the layer. The names of the layers will be referenced in our Phaser code, so let's change "Tiled Layer 1" to "Platforms":

Change Layer Name

To create a level, simply select a tile from your tileset and click where you'd like to place it on the map. Let's create/add all our platforms:

Add Platforms

Spikes in the Object Layer

In the Layers pane at the right of the screen, click the "New Layer" button and select "Object Layer". Name the layer "Spikes".

Add Spike Layer

At the top toolbar, select the "Insert Object" option:

Insert Object

Now we can add the spike tiles from the tileset:

Add Spikes

We've created our game level! Now we need to integrate it with Phaser.

Loading a Tiled Map

Phaser cannot read the .tmx file that Tiled created. First, let's export our map into JSON. Click on "File -> Export As", select JSON as the format and name it "level1.json" in the tilemaps folder. As with all Phaser projects, our assets need to be loaded in our preload function:

function preload() {
  this.load.image('background', 'assets/images/background.png');
  this.load.image('spike', 'assets/images/spike.png');
  // At last image must be loaded with its JSON
  this.load.atlas('player', 'assets/images/kenney_player.png','assets/images/kenney_player_atlas.json');
  this.load.image('tiles', 'assets/tilesets/platformPack_tilesheet.png');
  // Load the export Tiled JSON
  this.load.tilemapTiledJSON('map', 'assets/tilemaps/level1.json');
}

Note: You may be wondering why do we have to load the spike image separately if it's included in the tilemap. Unfortunately, this bit of duplication is required for objects to be displayed correctly.

In our create function, let's first add the background and scale it for our resolution:

const backgroundImage = this.add.image(0, 0,'background').setOrigin(0, 0);
backgroundImage.setScale(2, 0.8);

Then let's add our map:

const map = this.make.tilemap({ key: 'map' });

The key matches the name given in the preload function when we loaded the Tiled JSON. We also have to add the tileset image to our Phaser map object:

const tileset = map.addTilesetImage('kenney_simple_platformer', 'tiles');

The first argument of addTilesetImage is the name of the tileset we used in Tiled. The second argument is the key of the image we loaded in the preload function.

We can now add our platform layer:

const platforms = map.createStaticLayer('Platforms', tileset, 0, 200);

And should see this:

Platforms Added

By default, Phaser does not manage collisions for our tiled layers. If we added our player now, it would fall completely through the platform tiles. Let's tell Phaser that the layer can collide with other objects:

platforms.setCollisionByExclusion(-1, true);

Every tile in our map was given an index by Tiled to reference what should be shown there. An index of our platform can only be greater than 0. setCollisionByExclusion tells Phaser to enable collisions for every tile whose index isn't -1, therefore, all tiles.

Texture Atlas

Our player animation is stored in a texture atlas - an image containing smaller images. Similar to sprite sheets, they reduce network activity by loading one file. Most texture atlases contain much more than just sprite information.

Let's have a look at our image file: "kenney_player.png":

Kenney Player

Our atlas contains 8 frames: frames 0 to 3 are on top and frames 4 to 7 are below. By itself, this isn't that useful to Phaser, that's why it came with a JSON file: "kenney_player_atlas.json".

The file has a frames array which contains information about each individual picture that makes up the atlas.

To use the atlas you will need to know the filename property of the frames you are using.

Adding a Player

With our world set up we can add the player and have it interact with our platforms. In our create function let's add the following:

this.player = this.physics.add.sprite(50, 300, 'player');
this.player.setBounce(0.1);
this.player.setCollideWorldBounds(true);
this.physics.add.collider(this.player, platforms);

By default, Phaser uses the first frame of the atlas, if we wanted to begin on a different frame we could have added a next argument to the sprite method with the filename property of the atlas image e.g. robo_player_3.

The bounce property just adds a bit of vivacity when our player jumps and lands. And we set the player to collide with our game world and the platforms. We should now see our player standing on our platforms:

Player added

The purple box exists around our player because debug mode is enable for our physics engines. Debug mode shows the boundaries which determine how our sprites collide.

Adding Animations

Recall that our texture atlas had 8 frames for the player movement. Phaser allows us to create animations based on the frames of an atlas image. Let's create an animation for walking using the last two frames of the atlas' first row through our create function:

this.anims.create({
  key: 'walk',
  frames: this.anims.generateFrameNames('player', {
    prefix: 'robo_player_',
    start: 2,
    end: 3,
  }),
  frameRate: 10,
  repeat: -1
});

The key property is the string we use to play the animation later. The frames property is an array of frames in our atlas' JSON file that contains the animation. The animation begins at the first frame in the array and ends at the last. We use the helper function generateFrameNames to create the list of frame names for us, a very useful function for large atlas files.

The frameRate defaults to 24 frames per second, which may be a bit too quick for our player so we set it to 10. When we set repeat to -1 we are telling Phaser to run this animation infinitely.

Let's add the animations for our idle sprite, the first frame of the atlas:

this.anims.create({
  key: 'idle',
  frames: [{ key: 'player', frame: 'robo_player_0' }],
  frameRate: 10,
});

Our idle animation is simply one frame. Let's add an animation for when our player jumps, which is also just one frame:

this.anims.create({
  key: 'jump',
  frames: [{ key: 'player', frame: 'robo_player_1' }],
  frameRate: 10,
});

With our animations added, we then need to enable cursor keys so we can move our player:

this.cursors = this.input.keyboard.createCursorKeys();

Animating our Player

If our player moves left or right, then we want to walk. If we press spacebar or up, we want to jump. Otherwise, we will stay in our idle position. Let's implement this in our update function:

// Control the player with left or right keys
if (this.cursors.left.isDown) {
  this.player.setVelocityX(-200);
  if (this.player.body.onFloor()) {
    this.player.play('walk', true);
  }
} else if (this.cursors.right.isDown) {
  this.player.setVelocityX(200);
  if (this.player.body.onFloor()) {
    this.player.play('walk', true);
  }
} else {
  // If no keys are pressed, the player keeps still
  this.player.setVelocityX(0);
  // Only show the idle animation if the player is footed
  // If this is not included, the player would look idle while jumping
  if (this.player.body.onFloor()) {
    this.player.play('idle', true);
  }
}

// Player can jump while walking any direction by pressing the space bar
// or the 'UP' arrow
if ((this.cursors.space.isDown || this.cursors.up.isDown) && this.player.body.onFloor()) {
  this.player.setVelocityY(-350);
  this.player.play('jump', true);
}

Animating a sprite is as easy as setting the animation to true. If you were observant, you'll notice that our atlas only has rightward facing movements. If we are moving left, whether walking or jumping, we want to flip the sprite on the x-axis. If we moving to the right, we want to flip it back.

We can achieve this goal with the following bit of code:

if (this.player.body.velocity.x > 0) {
  this.player.setFlipX(false);
} else if (this.player.body.velocity.x < 0) {
  // otherwise, make them face the other side
  this.player.setFlipX(true);
}

Now our player moves around the game in a well-animated style!

Player Moving

Adding Spikes

Phaser provides us with many ways to get sprites from our object layer. The spikes are stored within an array in our tiled map object. Each spike would force our player to start over if it hits them. It makes sense for us to put all spikes in a sprite group and set up collisions between the player and the group. When a collision is set up with a sprite group, it's applied to all sprites.

In the create function add the following:

// Create a sprite group for all spikes, set common properties to ensure that
// sprites in the group don't move via gravity or by player collisions
this.spikes = this.physics.add.group({
  allowGravity: false,
  immovable: true
});

// Let's get the spike objects, these are NOT sprites
const spikeObjects = map.getObjectLayer('Spikes')['objects'];

// Now we create spikes in our sprite group for each object in our map
spikeObjects.forEach(spikeObject => {
  // Add new spikes to our sprite group, change the start y position to meet the platform
  const spike = this.spikes.create(spikeObject.x, spikeObject.y + 200 - spikeObject.height, 'spike').setOrigin(0, 0);
});

We should get this:

Spikes Added

The spike sprite's collision boundary is much higher than the spikes themselves. If left unchanged, that can create a bad game experience. Players would reset their position without hitting the sprite! Let's adjust the spikes' bodies to be smaller in size, particularly height. Replace the forEach with this:

spikeObjects.forEach(spikeObject => {
  const spike = this.spikes.create(spikeObject.x, spikeObject.y + 200 - spikeObject.height, 'spike').setOrigin(0, 0);
  spike.body.setSize(spike.width, spike.height - 20).setOffset(0, 20);
});

To keep the bounding box correctly encompassing the spikes we add an offset that matches the height reduction. Now we have more appropriate spike sprites:

Spikes Added

Collision with Player

If our player collides with a spike, their position is reset. It's common in platform games for players to have a 'lose' animation. Let's add a blinking animation when our player is reset. First, in the create let's add the collision:

this.physics.add.collider(this.player, this.spikes, playerHit, null, this);

The logic for the player reset will be in the playerHit function. Every time the player collides with a sprite from the spike sprite group, this function will be called. At the end of the file add the following:

function playerHit(player, spike) {
  player.setVelocity(0, 0);
  player.setX(50);
  player.setY(300);
  player.play('idle', true);
  player.setAlpha(0);
  let tw = this.tweens.add({
    targets: player,
    alpha: 1,
    duration: 100,
    ease: 'Linear',
    repeat: 5,
  });
}

Quite a few things are happening here. Let's take each instruction line by line:

  • Set the velocity of the player to 0. It's much more predictable (and safer) to stop the player's movement on restart
  • Set the X and Y coordinates to the player's first position
  • Use the idle animation, just as it was when the player started
  • The alpha property controls the opacity of a sprite. It's a value between 0 and 1 where 0 is fully transparent and 1 is fully opaque
  • Create a tween - an 'animation' of a property of a game object. The tween is applied to the player object that collided with the spike. It sets the alpha property to 1 (i.e. makes our player fully visible). This tween lasts 100ms, and the opacity increases linearly as noted by the ease property. It also repeats 5 times, hence why it looks like it's blinking.

Now our game looks like this:

Complete game

Note: Be sure to remove the debug: true property from the game configuration before you share it with friends, never leave debug mode in production!

Conclusion

With Tiled we can design both small and expansive 2D game worlds. It's best practice to create layers for depth within our game world. We then took the world we built in Tiled and added it to our Phaser game.

We added the platform layer as a static layer, making it immovable when the player collides. We then created a sprite group for the spikes and created a function to handle collisions between each spike and the player.

In addition to creating a vibrant game world, we learned how to animate our character using an atlas - a large image that contains multiple smaller images, accompanied by a JSON file detailing what image lies in each frame. We also used a tween to change a property of our sprite for a set period of time.

With these techniques, it's up to you to make the next best platformer with Phaser!

You can view the annotated source code for the game here.

Author image
Trinidad and Tobago Twitter Website
Web Dev|Games|Music|Art|Fun|Caribbean I love many things and coding is one of them!