In Parts 1 and 2 of this tutorial series, we started building our Phaser 3 MMORPG. In Part 2, we did the following:
- Refactored the client-side logic of our game.
- Updated the Player creation game logic.
- Added logic for creating the other players that joined the game.
- Added the logic for creating the enemy game objects.
In Part 3 of this tutorial series, we will finish our Phaser 3 MMORPG by doing the following:
- Add the logic for moving the enemy game objects.
- Add logic to allow the players to attack the enemy game objects.
- Add logic for allowing players to chat.
If you didn’t complete Part 2 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 3 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.
Enemy Movement
At the end of the last tutorial, we added the logic for creating the enemy game objects. Currently, the enemy game objects are generated in one random location and then they do nothing. To make the game a little more interesting, we will update the enemy game objects to randomly move around the map. Normally, we would want the enemies in our game to be more intelligent and we would have them fight back. That is outside the scope of this tutorial, so we recommend that you look into finding out how to do so in order to take your game further.
To do this, add the following code below the createEnemies
function in the WorldScene
class that is located in the public/assets/js/game.js
file:
moveEnemies () { this.spawns.getChildren().forEach((enemy) => { const randNumber = Math.floor((Math.random() * 4) + 1); switch(randNumber) { case 1: enemy.body.setVelocityX(50); break; case 2: enemy.body.setVelocityX(-50); break; case 3: enemy.body.setVelocityY(50); break; case 4: enemy.body.setVelocityY(50); break; default: enemy.body.setVelocityX(50); } }); setTimeout(() => { this.spawns.setVelocityX(0); this.spawns.setVelocityY(0); }, 500); }
In the code above, we did the following:
- First, we used the
getChildren
method on thespawns
Phaser Group object to get an array of all the child game objects that belong to that Group. - Then, we used the
forEach
method to loop through that array of game objects. - While looping through the game objects, we generate a random number between 1 and 4. We then use a
switch
statement to update either thex
ory
velocity of the enemy game object’s physics body. - Lastly, we use the
setTimeout
method to call a function after 500 milliseconds. In the function that is called, we update thex
andy
velocity of the enemy game object’s physics body to be 0.
Now, we just need to call the moveEnemies
function. We do not want to call this function every time the update
function is called, so we will use a Phaser Time event to trigger the moveEnemies
function. To do this, add the following code at the bottom of the createEnemies
function in the WorldScene
class:
// move enemies this.timedEvent = this.time.addEvent({ delay: 3000, callback: this.moveEnemies, callbackScope: this, loop: true });
In the code above, we did the following:
- First, we used
this.time.addEvent
to create a new Time Event. - When we called the
addEvent
method, we passed the following object as an argument:delay
– how long to wait before calling this function. When theloop
parameter is set totrue
, thedelay
parameter will also be used to control how long we wait between each call.callback
– the function to call when the event is triggered.callbackScope
– the scope that will be used when thecallback
function is called.loop
– used to have the event repeat. By default, this will be set tofalse
.
Now, if you save your code changes and refresh your browser, you should see that the enemy game objects will move randomly. Note: to start your server, you can run the following command in your terminal at the root of your project: npm run start
Attack Enemies
With the logic for moving enemy game objects in place, we will now work on adding the logic for allowing the player to attack enemies. In order to achieve this, we will create a new game object for the player’s weapon and add it to the player container. To do this, add the following logic to the createPlayer
function in the WorldScene
class above the this.updateCamera()
line:
// add weapon this.weapon = this.add.sprite(10, 0, 'sword'); this.weapon.setScale(0.5); this.weapon.setSize(8, 8); this.physics.world.enable(this.weapon); this.container.add(this.weapon); this.attacking = false;
Then, add the following line at the bottom of the createPlayer
function:
this.physics.add.overlap(this.weapon, this.spawns, this.onMeetEnemy, false, this);
Let’s review the code we just added:
- First, we created a new weapon game object using the
sword
sprite. We then scaled and set the size on game object by calling the following methods:setScale
andsetSize
. - We then enabled
physics
on the weapon game object. - Next, we added the new weapon game object to the player container object, and created a new variable called
attacking
and set it tofalse
. We will use this variable to keep track of when a player isattacking
. - Finally, we added a new
overlap
between the weapon game object and the enemies group. When these overlap, theonMeetEnemy
function will be triggered.
Next, we need to update the logic that is used in the onMeetEnemy
function in the WorldScene
class. Replace all of the logic in the onMeetEnemy
function with the following code:
onMeetEnemy(player, enemy) { if (this.attacking) { const location = this.getValidLocation(); enemy.x = location.x; enemy.y = location.y; } }
In the code above, we did the following:
- First, we checked to see if the player is attacking.
- If the player is attacking when the weapon collides with the enemy game object, then we get a new valid location for that enemy.
- Finally, we update the location of the enemy to be that new location.
If you save your code changes and refresh your browser, you should see that the player sprite now holds a weapon, and if you press the spacebar key the player should attack with the sword. If you try attacking an enemy, they should be moved to a new location.
Handling other player’s movement
Now that the player is can attack enemies, we will work on adding logic for moving other player’s sprites in our game. To handle other player’s movement, when a player moves in our game we will send a message to the server with that player’s location. Then, we will send those details to each of the other players.
Since we already have the logic in place on the server, we just need to update our client-side code. To do this, open public/assets/js/game.js
and add the following code at the bottom of the create
function in the WorldScene
class:
this.socket.on('playerMoved', function (playerInfo) { this.otherPlayers.getChildren().forEach(function (player) { if (playerInfo.playerId === player.playerId) { player.flipX = playerInfo.flipX; player.setPosition(playerInfo.x, playerInfo.y); } }.bind(this)); }.bind(this));
Let’s review the code we just added:
- First, we listened for the
playerMoved
message and when this message is received, aplayerInfo
object is also sent. - Inside the function that is triggered when the
playerMoved
message is received, we get the children of theotherPlayers
group and loop through them using theforEach
method. - When looping through the array of game objects, we check to see if the provided
playerId
of theplayerInfo
object matches the current game object. If theplayerId
is a match, then we update that game objectsflipX
property and update the x and y position of the game object.
Next, add the following code to the bottom of the update
function in the WorldScene
class:
// emit player movement var x = this.container.x; var y = this.container.y; var flipX = this.player.flipX; if (this.container.oldPosition && (x !== this.container.oldPosition.x || y !== this.container.oldPosition.y || flipX !== this.container.oldPosition.flipX)) { this.socket.emit('playerMovement', { x, y, flipX }); } // save old position data this.container.oldPosition = { x: this.container.x, y: this.container.y, flipX: this.player.flipX };
In the code above, we did the following:
- First, we get the
x
,y
, andflipX
values of the playercontainer
object and store those in a few variables. - Next, we check to see if the
container
game object has anoldPosition
property, and if it does, then we check to see if the current position of the player game object is different that the values stored in theoldPosition
property. - If the values are different, then we emit a
playerMovement
message along with an object that has thex
,y
, andflipX
values. - Lastly, we store those values in the
oldPosition
property of thecontainer
game object.
Now, if you save your code changes, refresh your browser, and open a new tab with an instance of the Phaser game, you should be able to test these latest changes. If you move your player by the other player’s location, you should be able to switch tabs and see that the other player’s sprite is now near your player’s sprite.
Adding chat
With logic for updating the other player’s game object in place, we will now work on adding chat to our game. For the chat in our game, we will add a simple div that will allow player’s to input messages, send them, and then on the server, we will save those messages in our database and broadcast those message to all player’s in our game.
To get started, open public/game.html
and replace the code in the body
tag with the following code:
<body> <div class="flex-container"> <div id="content"></div> <div id="chatContainer"> <div class="chatArea"> <ul id="messages" class="messages"></ul> </div> <input id="inputMessage" class="inputMessage" placeholder="Type here..." type="text"> </div> </div> <script src="assets/js/refreshToken.js"></script> <script src="assets/js/game.js"></script> </body>
In the code above, we added some divs for displaying our chat interface, an input
to allow player’s to enter text, and an ul
element for displaying the messages that players type. Lastly, we included the refreshToken
script that has the logic for refreshing the player’s token that is used for authentication.
Next, in public/assets/js/game.js
add the following code at the top of the file:
const inputMessage = document.getElementById('inputMessage'); const messages = document.getElementById('messages'); window.addEventListener('keydown', event => { if (event.which === 13) { sendMessage(); } if (event.which === 32) { if (document.activeElement === inputMessage) { inputMessage.value = inputMessage.value + ' '; } } }); function sendMessage() { let message = inputMessage.value; if (message) { inputMessage.value = ''; $.ajax({ type: 'POST', url: '/submit-chatline', data: { message, refreshToken: getCookie('refreshJwt') }, success: function(data) {}, error: function(xhr) { console.log(xhr); } }) } } function addMessageElement(el) { messages.append(el); messages.lastChild.scrollIntoView(); }
Let’s review the code we just added:
- First, we stored a reference to our
input
element and the messagesul
element. - Next, we added an event listener for the
keydown
event, and when this event is fired we check to see if the spacebar key and return keys were pressed.- If the return key was pressed, then we call a new function called
sendMessage
. - If the spacebar key was pressed, then we check to see if the
input
element is currently focused, and if it is then we update the value of the text input. The reason we are doing this is that Phaser is listening for our spacebar input and if you try pressing the spacebar key in the input element, the space is never added to the text.
- If the return key was pressed, then we call a new function called
- Then, we created a new function called
sendMessage
that will send a POST request to a new endpoint,submit-chatline
, that we will add to our server.- In the POST request, we send the chat message along with the user’s refresh token.
- Lastly, we created another new function called
addMessageElement
which will append a newli
element to the messagesul
element, and then we will have the messages scroll to that message.
Then, in the create
function in the WorldScene
class add the following code at the bottom of the function:
this.socket.on('new message', (data) => { const usernameSpan = document.createElement('span'); const usernameText = document.createTextNode(data.username); usernameSpan.className = 'username'; usernameSpan.appendChild(usernameText); const messageBodySpan = document.createElement('span'); const messageBodyText = document.createTextNode(data.message); messageBodySpan.className = 'messageBody'; messageBodySpan.appendChild(messageBodyText); const messageLi = document.createElement('li'); messageLi.setAttribute('username', data.username); messageLi.append(usernameSpan); messageLi.append(messageBodySpan); addMessageElement(messageLi); });
In the code above, we did the following:
- First, we listened for a new event message called
new message
. When this message is fired, the function will receive adata
object that has the player’s name that sent a message and the message that the player sent. - We then create a new
span
element, and added the player’s name to thatspan
element. - Next, we created another new
span
element, and we added the player’s message to thatspan
element. - Finally, we created a new
li
element, and added both of thespan
‘s we created to that element. We then called theaddMessageElement
function and passed the newli
element as an argument.
Now, we just need to add some logic to our server to handle the messages. In app.js
add the following code below the `app.use(‘/’, passport.authenticate(‘jwt’, { session : false }), secureRoutes);` line:
app.post('/submit-chatline', passport.authenticate('jwt', { session : false }), asyncMiddleware(async (req, res, next) => { const { message } = req.body; const { email, name } = req.user; // await ChatModel.create({ email, message }); io.emit('new message', { username: name, message, }); res.status(200).json({ status: 'ok' }); }));
Then, at the top of the file add the following code below the other require
statements:
const asyncMiddleware = require('./middleware/asyncMiddleware');
In the code above, we did the following:
- First, we created a new POST endpoint,
/submit-chatline
, and we authenticate the user that is calling that endpoint. - Next, we pull the user’s
message
from the request body, and then we pull the user’semail
andname
from the request user object. - We then call the
create
method on theChatModel
and pass this method the user’semail
andmessage
. For now, we commented out this line until we create that model. - Finally, we use
io.emit
to broadcast thenew message
event to users.
With the server updated, we can now test the changes. If you restart your server, refresh your browser and navigate back to the login page, and then log in, you should be taken to the game page. If you type some messages, you should see them appear on the screen.
Updating User Token
Currently, when a user sends a message, the name is appearing as undefined
. To fix this, we need to update the token we are storing for the user to include their name. To do this, open routes/main.js
and in the `router.post(‘/login’, async (req, res, next) => {` update the body
object to be:
const body = { _id: user._id, email: user.email, name: user.name, };
Then, update the following code:
// store tokens in memory tokenList[refreshToken] = { token, refreshToken, email: user.email, _id: user._id };
to be:
// store tokens in memory tokenList[refreshToken] = { token, refreshToken, email: user.email, _id: user._id, name: user.name };
Lastly, in the `router.post(‘/token’, (req, res) => {` function, update the body
object to be:
const body = { email: tokenList[refreshToken].email, _id: tokenList[refreshToken]._id, name: tokenList[refreshToken].name };
If you save your code changes, restart the server, and refresh the browser, you can test the new code. Once you log in, if you send a message, you should see your user’s name appear next to the message.
Storing messages in the database
To store the player’s messages in the database, we need to create a new model. To do this, create a new file called chatModel.js
in the models
folder. In this file, add the following code:
const mongoose = require('mongoose'); const Schema = mongoose.Schema; const ChatSchema = new Schema({ email: { type: String, required: true }, message: { type: String, required: true } }); const ChatModel = mongoose.model('chat', ChatSchema); module.exports = ChatModel;
In the code above, we created a new model called ChatModel
and in this model we will only be storing the user’s email
and the message
that they sent.
Then, in app.js
add the following code at the top of the file with the other require
statements:
const ChatModel = require('./models/chatModel');
Next, in the `/submit-chatline’` function, uncomment the following line:
await ChatModel.create({ email, message });
Lastly, you can uncomment the following code in app.js
:
app.get('/game.html', passport.authenticate('jwt', { session : false }), function (req, res) { res.sendFile(__dirname + '/public/game.html'); });
Attacking while chatting
With the chat model in place, the last thing we need to do is fix an issue when the player is typing a message. When the user is typing a message, if they press the spacebar key, the player will still attack. For our game, we only want the player to attack when they are focused on the game and when they are focused on the input box, the spacebar key should add a space.
To fix this, we just need to check if the input
is focused when we check if the player can attack in the update
function. In the update
function, replace the following line:
if (Phaser.Input.Keyboard.JustDown(this.cursors.space) && !this.attacking) {
with the following:
if (Phaser.Input.Keyboard.JustDown(this.cursors.space) && !this.attacking && document.activeElement !== inputMessage) {
Conclusion
With that player attacking issue fixed, that brings this tutorial series to an end.
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.