How to Create a Phaser MMORPG – Part 2

At the beginning of this tutorial series, we started building our Phaser 3 MMORPG. In Part 1, we did the following:

  • We set up the basic project and installed the required dependencies.
  • We added SocketIO to our project and added the server side logic for when a player connects and disconnects to our game.
  • We wrapped up by adding the SocketIO library to the client side code.

In Part 2 of this tutorial series, we will continue adding the web socket logic to client-side code, update the logic for adding players to our game and start adding the logic for allowing the players to attack.

If you didn’t complete Part 1 and would like to continue from here, you can find the code for it here.

You can download all of the files associated with the source code for Part 2 here.

Let’s get started!

""ACCESS NOW</a> </div> </div></div> </div></div> </div></div> </div></div></div>"}" data-sheets-userformat="{"2":2579,"3":{"1":0},"4":[null,2,16777215],"7":{"1":[{"1":2,"2":0,"5":[null,2,0]},{"1":0,"2":0,"3":3},{"1":1,"2":0,"4":1}]},"12":0,"14":{"1":2,"2":16777215}}">

BUILD GAMES

FINAL DAYS: Unlock 250+ coding courses, guided learning paths, help from expert mentors, and more.

Refactoring Client Logic

Before we start working on the logic for creating our player when the currentPlayers web socket message is received, we are going to refactor the logic in the methodcreate of our Phaser game. Currently, our methodcreatecontains a lot of logic and to make our game more manageable,so we are going to split the logic out into a few different functions.

To do this, open public/assets/js/game.js and replace all of the logic in the create function with the following code:

create() {
  this.socket = io();

  // create map
  this.createMap();

  // create player animations
  this.createAnimations();

  // create player
  this.createPlayer();

  // update camera
  this.updateCamera();

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

  // create enemies
  this.createEnemies();
}

createMap() {
  // create the map
  this.map = this.make.tilemap({
    key: 'map'
  });

  // first parameter is the name of the tilemap in tiled
  var tiles = this.map.addTilesetImage('spritesheet', 'tiles', 16, 16, 1, 2);

  // creating the layers
  this.map.createStaticLayer('Grass', tiles, 0, 0);
  this.map.createStaticLayer('Obstacles', tiles, 0, 0);

  // don't go out of the map
  this.physics.world.bounds.width = this.map.widthInPixels;
  this.physics.world.bounds.height = this.map.heightInPixels;
}

createAnimations() {
  //  animation with key 'left', we don't need left and right as we will use one and flip the sprite
  this.anims.create({
    key: 'left',
    frames: this.anims.generateFrameNumbers('player', {
      frames: [1, 7, 1, 13]
    }),
    frameRate: 10,
    repeat: -1
  });

  // animation with key 'right'
  this.anims.create({
    key: 'right',
    frames: this.anims.generateFrameNumbers('player', {
      frames: [1, 7, 1, 13]
    }),
    frameRate: 10,
    repeat: -1
  });

  this.anims.create({
    key: 'up',
    frames: this.anims.generateFrameNumbers('player', {
      frames: [2, 8, 2, 14]
    }),
    frameRate: 10,
    repeat: -1
  });

  this.anims.create({
    key: 'down',
    frames: this.anims.generateFrameNumbers('player', {
      frames: [0, 6, 0, 12]
    }),
    frameRate: 10,
    repeat: -1
  });
}

createPlayer() {
  // our player sprite created through the phycis system
  this.player = this.physics.add.sprite(50, 100, 'player', 6);

  // don't go out of the map
  this.player.setCollideWorldBounds(true);
}

updateCamera() {
  // limit camera to map
  this.cameras.main.setBounds(0, 0, this.map.widthInPixels, this.map.heightInPixels);
  this.cameras.main.startFollow(this.player);
  this.cameras.main.roundPixels = true; // avoid tile bleed
}

createEnemies() {
  // where the enemies will be
  this.spawns = this.physics.add.group({
    classType: Phaser.GameObjects.Zone
  });
  for (var i = 0; i < 30; i++) {
    var x = Phaser.Math.RND.between(0, this.physics.world.bounds.width);
    var y = Phaser.Math.RND.between(0, this.physics.world.bounds.height);
    // parameters are x, y, width, height
    this.spawns.create(x, y, 20, 20);
  }
  // add collider
  this.physics.add.overlap(this.player, this.spawns, this.onMeetEnemy, false, this);
}

In the code above, we moved all of the logic that was in the create function into separate functions, and we removed the collision logic for the object layer in our game.

If you save your code changes, restart the server, and visit http://localhost:3000/game.html in your browser, you should see that the game still loads. If you move your character around the map, you should notice that the character can now walk through the trees.

Updating the Player Creation Logic

With the create function logic refactored, we will now work on updating the player creation logic. To do this, replace the create function in the WorldScene class, in public/assets/js/game.js with the following code:

create() {
  this.socket = io();

  // create map
  this.createMap();

  // create player animations
  this.createAnimations();

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

  // create enemies
  this.createEnemies();

  // listen for web socket events
  this.socket.on('currentPlayers', function (players) {
    Object.keys(players).forEach(function (id) {
      if (players[id].playerId === this.socket.id) {
        this.createPlayer(players[id]);
      } else {
        this.addOtherPlayers(players[id]);
      }
    }.bind(this));
  }.bind(this));

  this.socket.on('newPlayer', function (playerInfo) {
    this.addOtherPlayers(playerInfo);
  }.bind(this));
}

In the code above, we did the following:

  • First, we removed the `this.updateCamera();` line. We will be moving this to the createPlayer function.
  • We then added event listeners for the currentPlayers and newPlayer web socket messages.
  • In the function that is triggered when the currentPlayers event is received, we create an array of all the keys in the players object and loop through them. We then check to see if that object’s playerId matches the socket id of the currently connected player.
    • If the id matches, then we call the createPlayer function and pass that function the player object.
    • If the id does not match, then we call a new function called addOtherPlayers and pass that function the player object.
  • In the function that is triggered when the newPlayer event is received, we call the addOtherPlayers function and pass that function the playerInfo object.

Next, replace the logic for the createPlayer function inside the WorldScene class with the following code:

createPlayer(playerInfo) {
  // our player sprite created through the physics system
  this.player = this.add.sprite(0, 0, 'player', 6);

  this.container = this.add.container(playerInfo.x, playerInfo.y);
  this.container.setSize(16, 16);
  this.physics.world.enable(this.container);
  this.container.add(this.player);

  // update camera
  this.updateCamera();

  // don't go out of the map
  this.container.body.setCollideWorldBounds(true);
}

In the code above, we did the following:

  • First, we updated the x and y position of the player game object to be 0. Since we will be placing the player game object inside a container, that object’s location will be relative to the container’s location.
  • Next, we created a new container and placed that container at the location of the playerInfo object that was created on the server.
  • Then, we enabled physics on the container and added the player game object to the container.
  • Finally, we called the updateCamera function and updated the setCollideWorldBound logic to be tied to the new container instead of the player game object.

Now, we need to update a few places in our code where we are referencing the player game object and replace it with the new container object. First, in the updateCamera function in the WorldScene class, update the following line: `this.cameras.main.startFollow(this.player);` to be:

this.cameras.main.startFollow(this.container);

Next, we will need to replace the update function logic in the WorldScene class with the following code:

update() {
  if (this.container) {
    this.container.body.setVelocity(0);

    // Horizontal movement
    if (this.cursors.left.isDown) {
      this.container.body.setVelocityX(-80);
    } else if (this.cursors.right.isDown) {
      this.container.body.setVelocityX(80);
    }

    // Vertical movement
    if (this.cursors.up.isDown) {
      this.container.body.setVelocityY(-80);
    } else if (this.cursors.down.isDown) {
      this.container.body.setVelocityY(80);
    }

    // Update the animation last and give left/right animations precedence over up/down animations
    if (this.cursors.left.isDown) {
      this.player.anims.play('left', true);
      this.player.flipX = true;
    } else if (this.cursors.right.isDown) {
      this.player.anims.play('right', true);
      this.player.flipX = false;
    } else if (this.cursors.up.isDown) {
      this.player.anims.play('up', true);
    } else if (this.cursors.down.isDown) {
      this.player.anims.play('down', true);
    } else {
      this.player.anims.stop();
    }
  }
}

In the code above, we did the following:

  • First, we added a check to see if the new container object existed. We did this to make sure we didn’t run any of our update logic until the container object has been created.
  • Next, we updated all of the setVelocity lines to be tied to the container game object instead of the player game object.

The last thing we need to do before we can test our code changes is to update the createEnemies logic. In the createEnemies function in the WorldScene class, remove the following code:

this.physics.add.overlap(this.player, this.spawns, this.onMeetEnemy, false, this);

Now, if you save your code changes and refresh your browser you should see that the game still loads and that the player is now at a new location.

If you refresh your browser, you should see that the player gets created at a new location each time.

Creating other players

With the logic in place for creating the main player, we will now add the logic for the addOtherPlayers function.  To keep track of the other players in our game, we will first need to create a new Phaser Physics Group. To do this, add the following line at the top of the create function inside the WorldScene class, below the this.socket = io() line:

this.otherPlayers = this.physics.add.group();

Then, add the following code below the createPlayer function in the WorldScene class:

addOtherPlayers(playerInfo) {
  const otherPlayer = this.add.sprite(playerInfo.x, playerInfo.y, 'player', 9);
  otherPlayer.setTint(Math.random() * 0xffffff);
  otherPlayer.playerId = playerInfo.playerId;
  this.otherPlayers.add(otherPlayer);
}

In the code above, we did the following:

  • First, we created a new game object using the information contained in the playerInfo object.
  • Then, we choose a random tint for the otherPlayer game object and stored that player’s id inside the otherPlayer object so we can reference that value later.
  • Finally, we added the otherPlayer game object to the otherPlayers group.

Now, if you save your code changes and refresh your browser, the game should load like before. However, if you open a new tab or window and navigate to the game page, you should see the other player in your game.

Note: you may need to move your player around the map to find the other player.

Handling Players disconnecting

Before we move on to creating our enemies, we have one issue we need to resolve with our player creation logic. Currently, if a player keeps refreshing their browser, or if they exit our game that player’s game object will stay in the other player’s game.

To fix this issue, we need to update our client-side logic to listen for the disconnect event, and when this event is received, we can delete that player’s game object. To do this, add the following code to the bottom of the create function:

this.socket.on('disconnect', function (playerId) {
  this.otherPlayers.getChildren().forEach(function (player) {
    if (playerId === player.playerId) {
      player.destroy();
    }
  }.bind(this));
}.bind(this));

Let’s review the code we just added:

  • First, we listened for the disconnect event and we defined a function that will be triggered when this event is triggered.
  • When this function is called, that function will receive the id of the player that disconnected. Inside this function, we create an array of all of the otherPlayer game objects by calling the getChildren method on the Phaser group. We then, use the forEach method to loop through the array.
  • While looping through the array of game objects, we check to see if that game objects playerId matches the provided playerId, and if it does then we destroy that game object. Now, if you save your code changes, refresh your browser, and load the game in multiple tabs, you should see that the individual player game objects are removed when they leave the game.

Creating Enemies

With the player creation logic out of the way, we will now start working on creating the enemies in our game. For this tutorial series, we will be creating the enemies on the client side of our game. Normally, we would want to create these enemies on our server and broadcast their location to the client side, however, this is out of scope for this tutorial.

For the enemies, we will be replacing the zones that are randomly placed around the map with player game objects. To do this, the first thing we need to do is load in the images that we will be using for enemies in our game. In the BootScene class, add the following code at the bottom of the preload method:

this.load.image('golem', 'assets/images/coppergolem.png');
this.load.image('ent', 'assets/images/dark-ent.png');
this.load.image('demon', 'assets/images/demon.png');
this.load.image('worm', 'assets/images/giant-worm.png');
this.load.image('wolf', 'assets/images/wolf.png');
this.load.image('sword', 'assets/images/attack-icon.png');

Then, replace all of the code in the createEnemies function in the WorldScene class with the following code:

createEnemies() {
  // where the enemies will be
  this.spawns = this.physics.add.group({
    classType: Phaser.GameObjects.Sprite
  });
  for (var i = 0; i < 20; i++) {
    const location = this.getValidLocation();
    // parameters are x, y, width, height
    var enemy = this.spawns.create(location.x, location.y, this.getEnemySprite());
    enemy.body.setCollideWorldBounds(true);
    enemy.body.setImmovable();
  }
}

In the code above, we did the following:

  • First, we changed the class type of the game object that was being created from Zone to Sprite.
  • Next, we changed the number of enemies created from 30 to 20.
  • Then, when we are creating the enemy game objects we call a new function called getValidLocation to get the location for where to place the enemy game object. Then, when we create the enemy game object we pass the location we just created and we use a new function called getEnemySprite to get the sprite for this enemy game object.
  • Finally, we call the setCollideWorldBounds and setImmovable methods on the enemy game object’s body.

Next, in the WorldScene class add the following code below the createEnemies function:

getEnemySprite() {
  var sprites = ['golem', 'ent', 'demon', 'worm', 'wolf'];
  return sprites[Math.floor(Math.random() * sprites.length)];
}

getValidLocation() {
  var validLocation = false;
  var x, y;
  while (!validLocation) {
    x = Phaser.Math.RND.between(0, this.physics.world.bounds.width);
    y = Phaser.Math.RND.between(0, this.physics.world.bounds.height);

    var occupied = false;
    this.spawns.getChildren().forEach((child) => {
      if (child.getBounds().contains(x, y)) {
        occupied = true;
      }
    });
    if (!occupied) validLocation = true;
  }
  return { x, y };
}

Let’s review the code we just added:

  • First, in the getEnemySprite function we created an array of the enemy sprites we loaded earlier. Then, we used Math.floor and Math.random to return a random sprite from that array.
  • Then, in the getValidLocation function we create a while loop and inside the while loop, we generate a random x and y position that is within the bounds of our game.
    • Once we do this, we loop through all of the enemy game objects.
    • For each of the enemy game objects, we get the bounds of that game object and we check to see if the x and y positions we created earlier are contained within those bounds.
    • If the x and y positions are not occupied, then we exit out of the while loop and return those positions.

Finally, we need to add a collider between the player container and the enemies group. To do this, add the following line at the bottom of the createPlayer function in the WorldScene class:

this.physics.add.collider(this.container, this.spawns);

Now, if you save your code changes and refresh your browser, you should see the new enemy game objects.

Conclusion

With the new enemy game objects in place, that brings this part of the tutorial to an end. In Part 3, we will wrap up the tutorial by doing the following:

  • Add logic for moving the enemy game objects.
  • Add logic for attacking the enemy game objects.
  • Add logic for allowing the players to chat.

I hope you enjoyed this and found it helpful! If you have any questions, or suggestions on what we should cover next, please let us know in the comments below.