Real-Time Chat in a Phaser Game with MongoDB and Socket.io
Rate this tutorial
When building a multiplayer game, you're probably going to want to implement a way to interact with other players beyond the general gameplay experience. This could be in the form of video, audio, or written chat within a game.
So how would you manage that real-time interaction and how would you store it in your database?
To get a better idea of what we hope to accomplish, take a look at the following animated image:
The actual game aspect in the above animation is a bit lackluster, but what's important is the chat functionality. In the above example, the chat messages and chat input are baked into the Phaser game. When we enter a message, it is sent to the server through sockets and the server saves the messages to MongoDB. In addition to saving, the server also broadcasts the messages back to every client and retrieves all messages for new clients.
There are a few moving pieces when it comes to this example, however, there aren't too many requirements. We'll need the following to be successful:
- Node.js 12+
Most of what we do will be accomplished using readily available JavaScript packages.
We're going to start by creating the backend for our game. It will do all of the heavy lifting for us that isn't related to visuals.
On your computer, create a new directory and run the following commands from within the directory:
1 npm init -y 2 npm install mongodb express cors socket.io --save
The above commands will install the MongoDB Node.js driver, Express, Socket.io, and a library for handling cross-origin resource sharing between the game and the server.
Next, create a main.js file within the project directory and add the following JavaScript code:
1 const express = require("express")(); 2 const cors = require("cors"); 3 const http = require("http").createServer(express); 4 const io = require("socket.io")(http); 5 const { MongoClient } = require("mongodb"); 6 7 const client = new MongoClient(process.env["ATLAS_URI"]); 8 9 express.use(cors()); 10 11 var collection; 12 13 io.on("connection", (socket) => { 14 socket.on("join", async (gameId) => {}); 15 socket.on("message", (message) => {}); 16 }); 17 18 express.get("/chats", async (request, response) => {}); 19 20 http.listen(3000, async () => { 21 try { 22 await client.connect(); 23 collection = client.db("gamedev").collection("chats"); 24 console.log("Listening on port :%s...", http.address().port); 25 } catch (e) { 26 console.error(e); 27 } 28 });
A lot of the above code is boilerplate when it comes to configuring Express and MongoDB. We'll do a quick breakdown on the pieces that matter as of right now.
First you'll notice the following line:
1 const client = new MongoClient(process.env["ATLAS_URI"]);
The
ATLAS_URI
is an environment variable on my computer. You can obtain the value to this variable within the MongoDB Atlas dashboard. The value will look something like this:1 mongodb+srv://<username>:<password>@cluster0-yyarb.mongodb.net/<database>?retryWrites=true&w=majority
You can choose to hard-code your value or use an environment variable like me. It doesn't matter as long as you know what you're choosing. Using an environment variable is beneficial as it makes the project easier to share without the risk of exposing potentially sensitive information that might be hard-coded.
The next thing you'll notice is the following:
1 http.listen(3000, async () => { 2 try { 3 await client.connect(); 4 collection = client.db("gamedev").collection("chats"); 5 console.log("Listening on port :%s...", http.address().port); 6 } catch (e) { 7 console.error(e); 8 } 9 });
After we connect to our MongoDB Atlas cluster, we obtain the collection that we plan to use. In this case, the database we plan to use is
gamedev
and the collection is chats
, neither of which need to exist prior to starting your application.With the basics added to the application, let's focus on the more important things, starting with the REST API endpoint:
1 express.get("/chats", async (request, response) => { 2 try { 3 let result = await collection.findOne({ "_id": request.query.room }); 4 response.send(result); 5 } catch (e) { 6 response.status(500).send({ message: e.message }); 7 } 8 });
Even though we're using Socket.io for most of our communication, it makes sense to have an endpoint for initially obtaining any chat data. It is not frequently accessed and it will prevent too much stress on the socket layer.
What we're saying in the endpoint is that we want to find a single document based on the
room
value that was passed in with the request. This value will represent our game room or our chat room, however you want to interpret it. This single document will have all of our previous chat conversations for the particular room. This data will be used to get the clients up to speed when they join.Now we can get into the real interesting things!
Let's revisit the Socket.io logic within the main.js file:
1 io.on("connection", (socket) => { 2 socket.on("join", async (gameId) => { 3 try { 4 let result = await collection.findOne({ "_id": gameId }); 5 if(!result) { 6 await collection.insertOne({ "_id": gameId, messages: [] }); 7 } 8 socket.join(gameId); 9 socket.emit("joined", gameId); 10 socket.activeRoom = gameId; 11 } catch (e) { 12 console.error(e); 13 } 14 }); 15 socket.on("message", (message) => {}); 16 });
You'll notice that this time we have some logic for the
join
event.When a
join
payload is received from the client, the gameId
that the client provides with the payload is used to try to find an existing MongoDB document. If a document exists, it means the chat room exists. If it doesn't, we should create one. After retrieving or creating a document in MongoDB, we can join the socket room with Socket.io, emit an event back to the client that we've joined, and specify that the active room is that of the gameId
that we just passed.A socket can be a part of multiple rooms, but we only care about one, the active one.
Now let's have a look at the
message
event for our sockets:1 io.on("connection", (socket) => { 2 socket.on("join", async (gameId) => { 3 // Logic here ... 4 }); 5 socket.on("message", (message) => { 6 collection.updateOne({ "_id": socket.activeRoom }, { 7 "$push": { 8 "messages": message 9 } 10 }); 11 io.to(socket.activeRoom).emit("message", message); 12 }); 13 });
When the client sends a message, we want to append it to the document defined by the active room. Remember, the room, game id, and document id are all the same thing and each socket maintains that information as well.
After the message is saved, its then broadcast to all sockets that are part of the same room.
The backend is now fully capable of handling the chat system within our game.
With the backend under control, now we can focus on the game that we can distribute to clients, otherwise known as other players.
Create a new directory on your computer and within that directory create an index.html file with the following HTML markup:
1 2 <html> 3 <head></head> 4 <body> 5 <div id="game"></div> 6 <script src="//cdn.jsdelivr.net/npm/socket.io-client@2/dist/socket.io.js"></script> 7 <script src="//cdn.jsdelivr.net/npm/phaser@3.24.1/dist/phaser.min.js"></script> 8 <script> 9 10 const phaserConfig = { 11 type: Phaser.AUTO, 12 parent: "game", 13 width: 1280, 14 height: 720, 15 backgroundColor: "#E7F6EF", 16 physics: { 17 default: "arcade", 18 arcade: { 19 gravity: { y: 200 } 20 } 21 }, 22 dom: { 23 createContainer: true 24 }, 25 scene: { 26 init: initScene, 27 preload: preloadScene, 28 create: createScene 29 } 30 }; 31 32 const game = new Phaser.Game(phaserConfig); 33 34 function initScene() {} 35 function preloadScene() {} 36 function createScene() {} 37 38 </script> 39 </body> 40 </html>
It may seem like there's a lot happening in the above HTML, but it is really just Phaser configuration.
First you'll notice the following two lines:
1 <script src="//cdn.jsdelivr.net/npm/socket.io-client@2/dist/socket.io.js"></script> 2 <script src="//cdn.jsdelivr.net/npm/phaser@3.24.1/dist/phaser.min.js"></script>
In the above lines we are including the Phaser game development framework and the Socket.io client library. These libraries can be used directly from a CDN or downloaded to your project directory.
Most of our boilerplate focuses around the
phaserConfig
object:1 const phaserConfig = { 2 type: Phaser.AUTO, 3 parent: "game", 4 width: 1280, 5 height: 720, 6 backgroundColor: "#E7F6EF", 7 physics: { 8 default: "arcade", 9 arcade: { 10 gravity: { y: 200 } 11 } 12 }, 13 dom: { 14 createContainer: true 15 }, 16 scene: { 17 init: initScene, 18 preload: preloadScene, 19 create: createScene 20 } 21 };
Because this is a game, we're going to enable game physics. In particular, we're going to use arcade physics and the environment gravity will be specific on the y-axis. While we won't be doing much in terms of the physics in this game, you can learn more about arcade physics in a previous tutorial I wrote titled, Handle Collisions Between Sprites in Phaser with Arcade Physics.
In the
phaserConfig
object you'll notice the dom
field. This will allow us to accept text input from the user directly within our Phaser game. We'll be doing something similar to what we saw in my tutorial, Maintaining a Geolocation Specific Game Leaderboard with Phaser and MongoDB.This brings us to our scene lifecycle functions.
The
initScene
function is where we initialize our variables and our connection to the backend server:1 function initScene() { 2 this.socket = io("http://localhost:3000", { autoConnect: false }); 3 this.chatMessages = []; 4 }
We're specifying
autoConnect
as false because we don't want to connect until our scene is finished being created. We'll manually connect in a different lifecycle function.The next lifecycle function is the
preloadScene
function, but before we get there, we should probably create our input form.Within your game project directory, create a new form.html file with the following HTML:
1 2 <html> 3 <head> 4 <style> 5 #input-form input { 6 padding: 10px; 7 font-size: 20px; 8 width: 250px; 9 } 10 </style> 11 </head> 12 <body> 13 <div id="input-form"> 14 <input type="text" name="chat" placeholder="Enter a message" /> 15 </div> 16 </body> 17 </html>
The styling information isn't too important, but the actual
<input>
tag is. Pay attention to the name
attribute on our tag as this will be important when we want to work with the data supplied by the user in the form.Within the
preloadScene
function of the index.html file, add the following:1 function preloadScene() { 2 this.load.html("form", "form.html"); 3 }
Now we're able to use our user input form within the game!
To display text and interact with the user input form, we need to alter the
createScene
function within the index.html file:1 function createScene() { 2 3 this.textInput = this.add.dom(1135, 690).createFromCache("form").setOrigin(0.5); 4 this.chat = this.add.text(1000, 10, "", { lineSpacing: 15, backgroundColor: "#21313CDD", color: "#26924F", padding: 10, fontStyle: "bold" }); 5 this.chat.setFixedSize(270, 645); 6 7 this.enterKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER); 8 9 this.enterKey.on("down", event => { 10 let chatbox = this.textInput.getChildByName("chat"); 11 if (chatbox.value != "") { 12 this.socket.emit("message", chatbox.value); 13 chatbox.value = ""; 14 } 15 }) 16 17 this.socket.connect(); 18 19 this.socket.on("connect", async () => { 20 this.socket.emit("join", "mongodb"); 21 }); 22 23 this.socket.on("joined", async (gameId) => { 24 let result = await fetch("http://localhost:3000/chats?room=" + gameId).then(response => response.json()); 25 this.chatMessages = result.messages; 26 this.chatMessages.push("Welcome to " + gameId); 27 if (this.chatMessages.length > 20) { 28 this.chatMessages.shift(); 29 } 30 this.chat.setText(this.chatMessages); 31 }); 32 33 this.socket.on("message", (message) => { 34 this.chatMessages.push(message); 35 if(this.chatMessages.length > 20) { 36 this.chatMessages.shift(); 37 } 38 this.chat.setText(this.chatMessages); 39 }); 40 41 }
There is a lot happening in the above
createScene
function and it can be a little tricky to understand. We're going to break it down to make it easy.Take a look at the following lines:
1 this.textInput = this.add.dom(1135, 690).createFromCache("form").setOrigin(0.5); 2 this.chat = this.add.text(1000, 10, "", { lineSpacing: 15, backgroundColor: "#21313CDD", color: "#26924F", padding: 10, fontStyle: "bold" }); 3 this.chat.setFixedSize(270, 645); 4 5 this.enterKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER);
The first line says that we are loading and positioning the input form that we had added to our project in the
preloadScene
function. Next we are creating a chat box to hold our text. We are positioning and styling the chat box to our liking. Finally, we are giving Phaser control of the enter key, something we'll use to submit the form in our game.Before we look at what happens with the enter key, take a look at the following lines:
1 this.socket.connect(); 2 3 this.socket.on("connect", async () => { 4 this.socket.emit("join", "mongodb"); 5 });
In the above lines we are manually connecting to our socket server. When the client socket says we have connected, we attempt to join a game. Remember the
join
we're waiting for on the server? This is where we are sending it and in this case we are saying we want to join the mongodb
game.After the server says we've joined, it sends us back a message which we can pick up here:
1 this.socket.on("joined", async (gameId) => { 2 let result = await fetch("http://localhost:3000/chats?room=" + gameId).then(response => response.json()); 3 this.chatMessages = result.messages; 4 this.chatMessages.push("Welcome to " + gameId); 5 if (this.chatMessages.length > 20) { 6 this.chatMessages.shift(); 7 } 8 this.chat.setText(this.chatMessages); 9 });
When we know we've joined, we can execute an HTTP request against the API endpoint and provide the room, which again is our game id. The response will be all of our chat messages which we'll add to an array. To eliminate the risk of messages displaying off the screen, we are popping items from the top of the array if the length is greater than our threshold.
When we call
setText
using an array value, Phaser will automatically separate each array item with a new line character.As of right now we've joined a game and we received all the prior messages. Now we want to send messages:
1 this.enterKey.on("down", event => { 2 let chatbox = this.textInput.getChildByName("chat"); 3 if (chatbox.value != "") { 4 this.socket.emit("message", chatbox.value); 5 chatbox.value = ""; 6 } 7 })
When the enter key is pressed, we get the
<input>
tag from the form.html file by the name
attribute. If the value is not empty, we can send a message to the server and clear the field.In our configuration, our own messages are not displayed immediately. We let the server decide what should be displayed. This means we need to listen for messages coming in:
1 this.socket.on("message", (message) => { 2 this.chatMessages.push(message); 3 if(this.chatMessages.length > 20) { 4 this.chatMessages.shift(); 5 } 6 this.chat.setText(this.chatMessages); 7 });
If a message comes in from the server we push it into our array. If that array is larger than our threshold, we pop an item from the top before rendering it to the screen. We're doing this because we don't want scrolling within our game and we don't want messages going off the screen. It isn't fool proof, but it will work good enough for us.
If you ran the game right now, you'd have something like this:
As you can see, we only have a chat area towards the right of the screen. Not particularly exciting for a game if you ask me.
What we're going to do is add a little flare to the game. Nothing extravagant, but just a little to remind us that we're actually doing game development with MongoDB.
Before we do that, go ahead and download the following image:
It really doesn't matter what image you use, but I'm saving you the trouble of having to find one for this game.
With the image inside of your project, we need to change the
preloadScene
function of the index.html file:1 function preloadScene() { 2 this.load.html("form", "form.html"); 3 this.load.image("leaf", "leaf.png"); 4 }
Notice that we're now loading the image that we just downloaded. The goal here will be to have many of these images bouncing around the screen.
Within the
createScene
function, let's make the following modification:1 function createScene() { 2 3 this.leafGroup = this.physics.add.group({ 4 defaultKey: "leaf", 5 maxSize: 15 6 }); 7 8 for(let i = 0; i < 15; i++) { 9 let randomX = Math.floor(Math.random() * 1000); 10 let randomY = Math.floor(Math.random() * 600); 11 let randomVelocityX = Math.floor(Math.random() * 2); 12 this.leafGroup.get(randomX, randomY) 13 .setScale(0.2) 14 .setVelocity([-100, 100][randomVelocityX], 200) 15 .setBounce(1, 1) 16 .setCollideWorldBounds(true) 17 } 18 19 // Chat and socket logic ... 20 }
In the above code we are creating an object pool with 15 possible sprites. Next we are looping and taking each of our 15 sprites from the object pool and placing them randomly on the screen. We're adding some physics information to each sprite and saying that they collide with the edges of our game.
This should cause 15 leaf sprites bounding around the screen.
You just saw how to add real-time chat to your Phaser game where each chat message is stored in MongoDB so it can be accessed later by new players in the game. The technologies we used were Phaser for the game engine, Socket.io for the real-time client and server interaction, and MongoDB for persisting our data.
I mentioned previously in this tutorial that I had created a different game that made use of user inputs. This article entitled, Maintaining a Geolocation Specific Game Leaderboard with Phaser and MongoDB, focused on creating leaderboards for games. I also wrote a tutorial titled, Creating a Multiplayer Drawing Game with Phaser and MongoDB, which demonstrated interactive gameplay, not just chat.