Building a Game with Phaser

Cecelia Martinez - Sep 15 '23 - - Dev Community

Welcome to Part Two of this four-part series on building a mobile game using open source technologies. We'll be using Phaser, along with Ionic, Capacitor, and Vue.

Are you interested in a video walkthrough version of this blog post? Let me know in the comments! If there's enough interest I can put one together.

The source code for this tutorial is here, and I'll reference specific commits throughout so you can see the specific changes for each section.

This post is heavily influenced by the tutorial from the official Phaser docs (we'll even use the assets they provide as well). I recommend going through the tutorial if you want to learn more about Phaser concepts. We'll keep it pretty high level here for brevity.

In this post, we'll actually walk through making the Phaser game! It's a big section and there are a lot of new concepts to learn, so let's jump right in!

Table of Contents

Creating Scenes

We talked about scenes briefly last week. Scenes are a core concept in Phaser and other game dev frameworks. You can think of Scenes like Views in a web app, but with some additional flexibility. Your player will navigate between Scenes, and sometimes multiple Scenes can run simultaneously.

Right now we have a single MainScene in our app.

In your game directory, create a PlayScene.js and ScoreScene.js file. We'll work with the PlayScene.js file first, as this will contain the majority of our game.

Add the following code to your Play scene:



// src/game/PlayScene.js

import { Scene } from "phaser";

export class PlayScene extends Scene {
    constructor () {
      super({ key: 'PlayScene' })
    }
    create () {
        this.add.text(100, 100, "PlayScene", {
            font: "24px Courier",
            fill: "#ffffff",
        });    
    }
  }


Enter fullscreen mode Exit fullscreen mode

Then, in your game.js file, remove the MainScene class we defined in the last blog post. We'll need to update our import statement and config to include the new PlayScene instead.



// src/game/game.js

import { Game, AUTO, Scale } from "phaser";
import { PlayScene } from "./PlayScene.js";

export function launch() {
    return new Game({
      type: AUTO,
      scale: {
        mode: Scale.RESIZE,
        width: window.innerWidth * window.devicePixelRatio,
        autoCenter: Scale.CENTER_BOTH,
        height: window.innerHeight * window.devicePixelRatio,
      },
      parent: "game",
      backgroundColor: "#201726",
      physics: {
        default: "arcade",
      },
      scene: PlayScene,
    });
  }


Enter fullscreen mode Exit fullscreen mode

It's not much, but now we have separate files for our scenes, which will make it easier to work with them.

Here is the git commit with the changes for this section.

Adding Game Objects

When working with Phaser, almost everything we interact with is a Game Object. Game Objects can:

  • Have Physics applied
  • Be images, text, sprites, etc.
  • Be Static or Dynamic
  • Be categorized into Groups
  • And much, much more.

For our game, we'll be working with the following objects:

  • A player sprite that moves left and right with animation
  • A platform static object that our player walks on
  • Left and right arrows for input to move our player
  • A star object that falls from the top of the screen and increases the score on collision with the player
  • A bomb object that falls from the top of the screen and ends the game on collision with the player

Within the public directory in the root of your project, create a new assets folder. Save the above linked images in this folder with the following file names:

  • player.png
  • platform.png
  • leftarrow.png
  • rightarrow.png
  • star.png
  • bomb.png

Detour: Adjusting Game Window

Before we move onto adding Game Objects, we need to make a small fix to our app that I discovered while writing. Right now, we have a collapsible header on iPhone. This could affect our game window size. Update the Play, About, and Scores pages in your /src/views directory to remove :fullscreen=true from the <ion-content> component, and remove the entire <ion-header> component and children that is inside <ion-content>. Your updated Page template should look like this:



// src/views/PlayPage.vue

<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-title>Play</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content >
        <PhaserContainer />
    </ion-content>
  </ion-page>
</template>


Enter fullscreen mode Exit fullscreen mode

Now we can move on!

Here is the git commit with the changes for this section.

Preloading Assets

Right now, inside our PlayScene class, we have a constructor and a create () method. There are two other lifecycle methods provided by Phaser: preload () and update ().

The preload () method is used to preload assets that will be used in our game.

Update your PlayScene.js file to preload the assets we just saved. Go ahead and also remove the text in the create () method.



// src/game/PlayScene.js

import { Scene } from "phaser";

export class PlayScene extends Scene {
    constructor () {
      super({ key: 'PlayScene' })
    }

    preload ()
{
    this.load.image('star', 'assets/star.png');
    this.load.image('bomb', 'assets/bomb.png');
    this.load.image('platform', 'assets/platform.png');
    this.load.image('leftArrow', 'assets/leftarrow.png');
    this.load.image('rightArrow', 'assets/rightarrow.png');
    this.load.spritesheet('player', 
        'assets/player.png',
        { frameWidth: 32, frameHeight: 48 }
    );
}

    create () {   
    }
  }



Enter fullscreen mode Exit fullscreen mode

The star, bomb, platform, and arrow images are added with this.load.image(), where we pass a unique key and the path for the image file.

The player is loaded using what's called a sprite sheet. A sprite sheet contains multiple poses of our player in the same image. This is helpful for animating our player. We use this.load.spritesheet(), again passing a key and path, but also setting the frameWidth and frameHeight of each individual pose (or "frame") in our image.

Displaying Images

Our asset are preloaded, but nothing is showing on the screen! We need to create game objects using our image assets.

Phaser uses pixels for all positioning and scaling. Many examples simply provide hard-coded values, which can work fine on browsers in a desktop device, but not for mobile.

Because of this, we need to do some math to make our displayed images responsive to the user's device screen.

NOTE I'd recommend playing around with these values based on the device screen. In a more complex example here, I am scaling everything and adding logic checks rather than hard-coding values. However, for simplicity, I'm hard-coding for an iPhone 12 Pro in this tutorial.

Phaser provides this.scale.width and this.scale.height that returns the current screen width and height values. We can use these to calculate the center of our screen and where we should place objects.

If we think about our game layout, we'll need to calculate the height of a game play area, as well as the height of a controls area. This will help us determine where on the Y axis we need to place our platform, player, and arrow controls.

Mockup of game with designated areas

NOTE: We need to calculate the game size within our create () method and not in our gameState because the game needs to be initiated first to access the screen size values.

Add the following code to your create () method.



//game/PlayScene.js
...
create () {
    // sets game values based on screen size
    this.screenWidth = this.scale.width;
    this.screenHeight = this.scale.height;
    this.screenCenterX = this.screenWidth / 2;
    this.controlsAreaHeight = this.screenHeight * 0.2;
    this.gameAreaHeight = this.screenHeight - this.controlsAreaHeight;

    // adds the player, platform, and controls
    this.platform = this.physics.add.staticImage(0, this.gameAreaHeight, 'platform').setOrigin(0, 0).refreshBody();
    this.player = this.physics.add.sprite(this.screenCenterX, this.gameAreaHeight - 24, 'player');
    this.leftArrow = this.add.image(this.screenWidth * 0.1, this.gameAreaHeight + 40, 'leftArrow').setOrigin(0, 0).setInteractive()
    this.rightArrow = this.add.image(this.screenWidth * 0.7, this.gameAreaHeight + 40, 'rightArrow').setOrigin(0, 0).setInteractive()
}


Enter fullscreen mode Exit fullscreen mode

In the first section, we've calculated the center of the screen. We've also set a controlsAreaHeight to take up about 20% of the bottom of the screen, and the gameAreaHeight to be the difference.

To create a game object, we'll use the relevant add method, passing an X coordinate, Y coordinate, and image key.

Let's break these down individually.



  this.platform = this.physics.add.staticImage(0, this.gameAreaHeight, 'platform').setOrigin(0, 0).refreshBody();


Enter fullscreen mode Exit fullscreen mode

We've added the platform as a static image, which gives it physics properties so other game objects can interact with the platform. One thing to note here is setOrigin(). By default Phaser positions images based on the center of the image. By changing the origin to (0, 0), I am telling Phaser to position starting from the bottom left corner instead. Whenever we change the position or scale of a static body, we need to tell Phaser to adjust for this change. That is what refreshBody() is doing here.



 this.player = this.physics.add.sprite(this.screenCenterX, this.gameAreaHeight - 24, 'player');


Enter fullscreen mode Exit fullscreen mode

For the player, I'm adding a sprite. For the Y coordinate, I'm doing some math and placing the player at the gameAreaHeight, but subtracting half the player height. This is because, again, Phaser positions from the center of the image. This results in the player standing nicely on top of the platform.

If I wanted to, I could use setOrigin() to position from the bottom instead, but I wanted to demonstrate how you would position an item by default in Phaser as well.



this.leftArrow = this.add.image(this.screenWidth * 0.1, this.gameAreaHeight + 40, 'leftArrow').setOrigin(0, 0).setInteractive()
this.rightArrow = this.add.image(this.screenWidth * 0.7, this.gameAreaHeight + 40, 'rightArrow').setOrigin(0, 0).setInteractive()


Enter fullscreen mode Exit fullscreen mode

For the arrows, I'm sticking with setOrigin(0, 0) because it's easier for me to position based on the lower left corner with the math I'm doing. I'm positioning the arrows 40 pixels (half the size of the arrows) below the gameAreaHeight, the left arrow 10% of the way from the left and the right arrow 70% of the way from the left of the screen edge.

I'm using setInteractive() so that we can assign touch/click handlers to the arrows.

Animating Game Objects

If you save what we have so far, you see our player standing on the platform and facing the left. This is because Phaser is using the first frame of our spritesheet by default.

Screenshot of app so far

Let's set up some animations so when our character moves, we can leverage all the poses in the spritesheet. This code comes mostly from the official Phaser tutorial, so I won't dig into too much. I did add a logic check so it doesn't create a new animation if one already exists when the game restarts.

But you can see we are defining an animation key, providing an image key, stating what frames to loop through, and establishing a loop for each animation. The "turn" animation is the player simply facing forward.



//game/PlayScene.js

create () {
...
     // adds animations for player
     if (!this.anims.exists('left')) {
        this.anims.create({
          key: "left",
          frames: this.anims.generateFrameNumbers('player', { start: 0, end: 3 }),
          frameRate: 10,
          repeat: -1,
        });
      }

      if (!this.anims.exists('turn')) {
        this.anims.create({
          key: "turn",
          frames: [{ key: 'player', frame: 4 }],
        });
      }

      if (!this.anims.exists('right')) {
        this.anims.create({
          key: "right",
          frames: this.anims.generateFrameNumbers('player', { start: 5, end: 8 }),
          frameRate: 10,
          repeat: -1,
        });
      }
}


Enter fullscreen mode Exit fullscreen mode

Our player is still facing left because we'll leverage these animations once our player starts moving in the next section.

Here is the git commit with the changes for this section.

Responding to Player Input

In order for our player to animate, he needs to move! Let's add some event handlers for our arrow buttons next.

First, we need to make sure our player is ready for the physics of moving and interacting with our game world.

Setting Player Physics

Add the following code inside your create () method, underneath the animations we just added.



// src/game/PlayScene.js

// sets player physics
this.player.body.setGravityY(300);
this.player.setCollideWorldBounds(true);

// adds collider between player and platforms
this.physics.add.collider(this.player, this.platform);


Enter fullscreen mode Exit fullscreen mode

For gravity, we could set a default game-level gravity. However, we want our player to move differently than our stars and bombs, so we're setting it on the object directly instead. setColliderWorldBounds() to true means our player cannot go off screen, and adding a collider between the player and platform means the player will stay on top of the platform as he moves.

Adding Event Handlers

Now we'll add event handlers for pointerdown and pointerup events. These will translate to touch events once we're on a mobile device.

Still in our create () method, add the following:



// src/game/PlayScene.js

// event handlers for arrow input
this.moveLeft = false;
this.moveRight = false;

this.leftArrow.on('pointerdown', () => {
this.moveLeft = true;
});

this.leftArrow.on('pointerup', () => {
this.moveLeft = false;
});

this.rightArrow.on('pointerdown', () => { 
this.moveRight = true;
});

this.rightArrow.on('pointerup', () => {
this.moveRight = false;
});


Enter fullscreen mode Exit fullscreen mode

We are using two variables, moveLeft and moveRight, to track whether our player is in motion based on what arrows are being pressed.

However, we are not actually telling the player to move yet. Where do we do that? In our update () method.

Handling movement in update method

So far we have primarily been working in our create () method, which is executed when the game is initialized.

In comparison, the update () method runs every frame of the game. This is where we can control actions that need to update once the game has already started.

Inside our update () method, add the following:



// src/game/PlayScene.js

update () {
  if (this.moveLeft && !this.moveRight) {
    this.player.setVelocityX(0 - 200);   
    this.player.anims.play('left', true);
  }

  else if (this.moveRight && !this.moveLeft) {
     this.player.setVelocityX(200);    
     this.player.anims.play('right', true);
  }

  else {
    this.player.setVelocityX(0);
    this.player.anims.play('turn');
  }
}


Enter fullscreen mode Exit fullscreen mode

Here, when moveLeft is set to true by the pointerdown event handler, we set the player velocity to move left, and play the left animation. We handle the moveRight boolean change the same way.

By default, our player is not moving in either direction, with the turn animation playing, which results in the player facing forward.

If you save now, you can try it out for yourself!

Here is the git commit with the changes for this section.

Dynamically Generating Game Objects

Our player can now move in the game world, but it's pretty boring. Let's give him something to do by generating stars for him to collect and bombs for him to avoid.

Add the following code to the end of your create () method:



// src/game/PlayScene.js
...
create () {
...
// Adds generated stars

this.stars = this.physics.add.group({
  gravityY: 300,
});

const createStar = () => {
  const x = Math.random() * this.screenWidth;
  const star = this.stars.create(x, 0, 'star');
}

const createStarLoop = this.time.addEvent({
  // random number between 1 and 1.2 seconds
  delay: Math.floor(Math.random() * (1200 - 1000 + 1)) + 1000,
  callback: createStar,
  callbackScope: this,
  loop: true,
});
}


Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in each block.

First, we are creating a physics group called stars. This is helpful when we have a category of game objects and want the same physics applied to each individual game object in that group. In this case, we are applying a gravityY of 300.

Next, we are writing a function called createStar that creates a new star object within our stars group, placing it at a random X coordinate and the 0 Y coordinate (top of the screen) using the key for our star image.

Finally, we are using this.time.addEvent provided by Phaser to create a loop. This Phaser method works like setTimeout, in that you provide a delay in MS and a callback function. We are also referencing our this object for the callbackScope and setting loop to true so it repeats.

Once you save and refresh, you'll see stars falling from the sky at random X positions and at random intervals between 1 and 1.2 seconds.

We'll repeat the process for our bombs. However, we want our bombs to drop much faster but also appear less frequently.

Add the following code to create ():



 // src/game/PlayScene.js
...
create () {
...
// Adds generated bombs

this.bombs = this.physics.add.group({
  gravityY: 900,
});

const createBomb = () => {
  const x = Math.random() * this.screenWidth;
  const bomb = this.bombs.create(x, 0, 'bomb');
  bomb.setScale(2).refreshBody();
}

const createBombLoop = this.time.addEvent({
  // random number between 4.5 and 5 seconds
  delay: Math.floor(Math.random() * (5000 - 4500 + 1)) + 4500,
  callback: createBomb,
  callbackScope: this,
  loop: true,
});


Enter fullscreen mode Exit fullscreen mode

Now we have bombs that drop every 4.5-5 seconds at a high speed. I also scaled up the bombs with setScale(2).refreshBody() to double the original image size so they are easier to see as they fall.

Screenshot of stars and bombs falling

You should now see stars and bombs falling down on your player!

Here is the git commit with the changes for this section.

Adding Collision & Overlap Handlers

We are almost done! All we have to do now is handle what happens when our player interacts with the stars and bombs.

Adding Colliders

You may remember that we added a collider earlier between the player and platform.



this.physics.add.collider(this.player, this.platform);


Enter fullscreen mode Exit fullscreen mode

When we add a collider in Phaser without a callback, the default behavior is for the two objects to simply block or push against each other upon collision, without passing through each other.

We can set a collider with a callback function to complete additional steps when two items collide.

For example, our stars and bombs should not go past the platform. Let's add a collider for these and destroy the star or bomb when it collides with the platform.



 // src/game/PlayScene.js
...
create () {
...
// Adds colliders between stars and bombs with platform

this.physics.add.collider(this.stars, this.platform, function(object1, object2) {
  const star = (object1.key === 'star') ? object1 : object2;
  star.destroy();
});

this.physics.add.collider(this.bombs, this.platform, function(object1, object2) {
  const bomb = (object1.key === 'bomb') ? object1 : object2;
  bomb.destroy();
});


Enter fullscreen mode Exit fullscreen mode

This code will trigger the callback function whenever the two objects collide, determine which object is the object we went to destroy, then call the Phaser-provided destroy() method on that object. Our stars and bombs should now disappear when they collide with the platform.

Adding Overlaps

Finally, we need to add overlap handlers that:

  • increase the game score when our player overlaps with a star
  • ends the game when the player overlaps with a bomb

An overlap is different than a collider in that it only checks if two objects overlap, rather than preventing them from colliding. We'll use overlap for the interactions between our player with stars and bombs.

Add the following, again to your create () method:



// src/game/PlayScene.js
...
create () {
...

// Adds overlap between player and stars

this.score = 0;
this.scoreText = this.add.text(this.screenCenterX, this.gameAreaHeight + 16, 'Score: 0', { fontSize: '16px', fill: '#000' }).setOrigin(0.5, 0.5);

this.physics.add.overlap(this.player, this.stars, function(object1, object2) {
  const star = (object1.key === 'player') ? object1 : object2;
  star.destroy();
  this.score += 10;
  this.scoreText.setText('Score: ' + this.score);
}, null, this);


Enter fullscreen mode Exit fullscreen mode

In the first block, we are creating a starting score of 0 and displaying that text on the screen over the platform. This should look familiar to how we created other game objects, passing an X and Y value and then the text to this.add.text(). We can also set CSS values in an object passed to the fourth parameter.

Then, we are creating an overlap between the player and the stars group with a callback function that does four things:

  • Checks which overlap object is the star
  • Destroys the star
  • Increases the score by 10 points
  • Resets the score text with this.scoreText.setText()

We need to pass the additional null and this parameters to this.physics.add.overlap() so that we have access to our this object inside the callback function.

We can repeat this for our bombs.



// src/game/PlayScene.js
...
create () {
...
// Adds overlap between player and bombs

this.physics.add.overlap(this.player, this.bombs, function(object1, object2) {
  const bomb = (object1.key === 'player') ? object1 : object2;
  bomb.destroy();

  createStarLoop.destroy();
  createBombLoop.destroy();
  this.physics.pause();

  this.gameOverText = this.add.text(this.screenCenterX, this.screenHeight / 2, 'Game Over', { fontSize: '32px', fill: 'red' }).setOrigin(0.5, 0.5);

  this.input.on('pointerup', () => {
    this.score = 0;
    this.scene.restart();
  })
}, null, this);


Enter fullscreen mode Exit fullscreen mode

This is similar to our star overlap in that we are checking which object is the bomb, then destroying it. However, after that, we have functionality that handles the end of the game.



createStarLoop.destroy();
createBombLoop.destroy();
this.physics.pause();


Enter fullscreen mode Exit fullscreen mode

These lines essentially stop our game world by destroy the loops that create new stars and bombs, as well as pausing all the physics taking place in the game.

Then, we add "Game Over" text to the middle of the screen.

Finally, we are adding a pointerup event handler that lets the user restart the game. This resets the score to 0 and restarts the game on click/tap. It's important that this event handler is inside your overlap callback so it only fires after the player interacts with a bomb.

Screenshot of Game Over screen

Congratulations, you now have a game!

Here is the git commit with the changes for this section.

What's Next

Next week, we'll create our Score scene, talk about transitioning scenes, as well as how to interact between our Phaser game and the Ionic Vue app by exporting scores.

Stay tuned!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .