Manage Game User Profiles with MongoDB, Phaser, and JavaScript
Rate this tutorial
When it comes to game development, you're almost always going to need to
store information about your player. This information could be around
how many health points you currently have in the game or it can extend
beyond the game-play experience and into details such as the billing
information for the person playing the game. When we talk about this
type of data, we're talking about a user profile store.
The user profile has everything about the user or player and doesn't end
at health points or billing information.
In this tutorial, we're going to look at creating user profiles in a
game that leverages the Phaser game development
framework, JavaScript, and MongoDB.
To get a better idea of what we're going to accomplish, take a look at
the following animated image:
Alright, so we're not making a very exciting game. However, many modern
games let you design your character either before the game starts or at
some point as you progress through the game. These customizations that
you add to your character are stored within the user profile. In our
example, we're going to customize our player and then send the
information to our back end for storing in MongoDB.
There are a few components to this game, but we'll be leveraging a
JavaScript stack throughout. To be successful with this tutorial, you'll
need the following:
- Node.js 12+
- A properly configured MongoDB cluster with a gamedev database and a profiles collection
We'll be using Node.js to create a back end API that will communicate
with our MongoDB cluster. The game, which will be our front end, will
communicate with our Node.js back end. We won't go into the details on
how to configure and deploy a MongoDB instance, but you'll need one with
a database and collection available for use.
If you want to quickly get up and running with MongoDB, consider
deploying a MongoDB Atlas M0
cluster, which is FREE forever!
To create a Phaser game, you don't need anything beyond basic HTML, CSS,
and JavaScript, so there aren't any prior requirements for the game
component.
Before we get into the code, it is probably a good idea to explore what
our user profile should contain. These aren't blanket rules for all user
profiles because often, games are not the same.
Based on the animated image toward the beginning of this tutorial, we
know we have the following JSON:
1 { 2 "_id": "nraboy", 3 "cosmetics": { 4 "eyes": "player-eyes-1", 5 "mouth": "player-mouth-1" 6 }, 7 "hp": 100, 8 "mp": 30 9 }
The depth of the above profile is hardly anything, but we know for this
particular player that they've decided to give their character a certain
appearance and that character has a particular set of attributes such as
health points.
If you want to see a more aggressive user profile, check out my
previous
tutorial,
which is focused on a Unity game that I'm building with Adrienne
Tacke.
You might consider adding a hashed password, player position within the
game, stored items, or any other relevant field to your user profile.
What makes MongoDB particularly powerful when it comes to gaming and
user profiles is how flexible the data model can be. If we release an
update to our game that includes customizations to the color of the
diamond, we could easily add that field to our NoSQL documents. Adding
and removing fields requires no special migrations or coding, which
translates to faster development and better code.
We have a rough idea of what our user profile should look like for this
particular game. Now we need to be able to add it to MongoDB and query
for it. To do this, we're going to create a back end with two simple API
endpoints: an endpoint for creating a document with the profile and an
endpoint for retrieving it.
For this particular tutorial, we are not going to worry about
authentication. In other words, we are only going to be creating user
profiles and not caring who they truly belong to.
Within a new directory on your computer, execute the following commands:
1 npm init -y 2 npm install cors express body-parser mongodb --save
The above commands will create a new package.json file and download
the dependencies that we plan to use. We're using the
cors package because we plan to
run our game locally within a web browser. If we don't manage
cross-origin resource sharing (CORS), we're going to get errors. The
express and
body-parser packages will
be for our development framework and the
mongodb package will be for
interaction with MongoDB.
Within the same project, create a main.js file with the following
code:
1 const { MongoClient, ObjectID } = require("mongodb"); 2 const Express = require("express"); 3 const BodyParser = require("body-parser"); 4 const Cors = require("cors"); 5 6 const server = Express(); 7 8 server.use(BodyParser.json()); 9 server.use(BodyParser.urlencoded({ extended: true })); 10 server.use(Cors()); 11 12 const client = new MongoClient(process.env["ATLAS_URI"]); 13 14 var collection; 15 16 server.post("/profile", async (request, response, next) => {}); 17 server.get("/profile/:username", async (request, response, next) => {}); 18 19 server.listen("3000", async () => { 20 try { 21 await client.connect(); 22 collection = client.db("gamedev").collection("profiles"); 23 console.log("Listening at :3000..."); 24 } catch (e) { 25 console.error(e); 26 } 27 });
The core of what is happening in the above code is we are laying the
foundation to our Express Framework application by importing the
dependencies and initializing the framework. When we start listening for
connections, then we connect to MongoDB and define which database and
collection we want to use. In this example, we are using a
gamedev
database and a profiles
collection. You can use whatever makes sense
to you.The actual connection information for MongoDB is defined with an
ATLAS_URI
environment variable. You could easily use a configuration
file or hard-code this value, but environment variables tend to work out
the best for security and deployment reasons.The connection string to be used would look something like this:
1 mongodb+srv://<username>:<password>@plummeting-us-east-1.hrrxc.mongodb.net/<dbname>
You can gather the information from your MongoDB Atlas dashboard after
creating the appropriate user credentials for your database and
collection.
When I add an environment variable, I generally do something like the
following:
1 export ATLAS_URI="mongodb+srv://<username>:<password>@plummeting-us-east-1.hrrxc.mongodb.net/<dbname>"
There are numerous ways to do this and it may vary depending on your
operating system.
So let's focus on those endpoints.
When it comes to creating a user profile within MongoDB, we might have
an endpoint that looks like the following:
1 server.post("/profile", async (request, response, next) => { 2 try { 3 let result = await collection.insertOne(request.body); 4 response.send(result); 5 } catch (e) { 6 response.status(500).send({ message: e.message }); 7 } 8 });
The above endpoint is probably the most basic endpoint you can create.
We are accepting a payload from the front end and we are inserting it
into the database. Then we are returning the response that the database
gives us.
In a production game, you're probably going to want to validate your
data to prevent cheating or other malicious activity. You can look into
Schema Validation with MongoDB or use a client-facing package like
joi instead.
With data going into MongoDB, now we can create our endpoint for
retrieving it:
1 server.get("/profile/:username", async (request, response, next) => { 2 try { 3 let result = await collection.findOne({ "_id": request.params.username }); 4 response.send(result); 5 } catch (e) { 6 response.status(500).send({ message: e.message }); 7 } 8 });
After supplying this endpoint a username, which in our example is the
_id
field, we use it to find one particular document. That one
document is returned to the game or whatever tried to retrieve it.Again, we are not using any data validation or authentication for this
tutorial as it is outside the scope of what we want to accomplish.
We now have an API to work with.
With the API in place, we can focus on the game itself. Remember, the
depth of our game is quite shallow, but it offers a feature that a lot
of games offer. That feature is customizing the character.
I'm not going to share my graphic assets because I want you to use some
imagination for creating your own. Will you create something better?
Probably, but it's on you!
Create an index.html file on your computer with the following HTML
markup:
1 2 <html> 3 <body> 4 <div id="game"></div> 5 <script src="//cdn.jsdelivr.net/npm/phaser@3.51.0/dist/phaser.min.js"></script> 6 <script> 7 8 const phaserConfig = { 9 type: Phaser.AUTO, 10 parent: "game", 11 width: 1280, 12 height: 720, 13 scene: { 14 init: initScene, 15 preload: preloadScene, 16 create: createScene 17 } 18 }; 19 20 const game = new Phaser.Game(phaserConfig); 21 22 function initScene() {} 23 function preloadScene() {} 24 function createScene() {} 25 26 </script> 27 </body> 28 </html>
The above markup is standard Phaser boilerplate. We define the container
where the game should be loaded, import the JavaScript dependency, and
initialize the game while telling it to use the container.
The real magic is going to happen in the
scene
lifecycle functions.
The initScene
function is where we'll initialize our variables, the
preloadScene
function is where we'll load our media assets, and the
createScene
function is where we'll do our first render.Starting with the
initScene
function, add the following:1 function initScene() { 2 3 this.player = { 4 "_id": "nraboy", 5 "cosmetics": { 6 "eyes": "", 7 "mouth": "" 8 }, 9 "hp": 100, 10 "mp": 30 11 } 12 this.eyes = []; 13 this.mouth = []; 14 15 }
For our example, the
player
object will represent our locally stored
user profile object. It will look exactly like what you'd find stored in
the database. This object could be as simple or as complex as you want
to make it depending on your game needs. The _id
field must be unique,
so if you'd prefer to let MongoDB generate it, remove the field and
create a username
or similar field instead. It's totally up to you.The
eyes
and mouth
arrays will represent all possible options for
character customizations. The chosen customization will be stored in the
user profile.The next step is to look at the
preloadScene
function:1 function preloadScene() { 2 3 this.load.image("background", "game-background.png"); 4 this.load.image("player-base", "player-base.png"); 5 this.load.image("player-eyes-1", "player-eyes-1.png"); 6 this.load.image("player-eyes-2", "player-eyes-2.png"); 7 this.load.image("player-mouth-1", "player-mouth-1.png"); 8 this.load.image("player-mouth-2", "player-mouth-2.png"); 9 10 }
In the above function, we are loading our image files and giving them a
reference name to be used throughout the game. In the above example, the
reference name is a pretty close name to the filename itself, but it
doesn't have to be.
I didn't provide the image assets, so feel free to be adventurous.
The magic happens in the
createScene
function:1 function createScene() { 2 3 this.add.image(640, 360, "background"); 4 this.add.image(800, 250, "player-base"); 5 this.eyesActive = this.add.image(800, 250, "player-eyes-1"); 6 this.mouthActive = this.add.image(800, 250, "player-mouth-1"); 7 8 for (let i = 1; i <= 2; i++) { 9 this.eyes.push( 10 this.add.image(75, 90 * i, "player-eyes-" + i) 11 .setCrop(150, 115, 100, 50) 12 .setScale(0.5) 13 .setInteractive() 14 .on("pointerdown", () => { 15 this.eyesActive.setTexture("player-eyes-" + i); 16 }) 17 ); 18 this.eyes[i - 1].input.hitArea = new Phaser.Geom.Rectangle(150, 115, 100, 50); 19 } 20 21 for (let i = 1; i <= 2; i++) { 22 this.mouth.push( 23 this.add.image(215, 90 * i - 40, "player-mouth-" + i) 24 .setCrop(150, 195, 100, 50) 25 .setScale(0.5) 26 .setInteractive() 27 .on("pointerdown", () => { 28 this.mouthActive.setTexture("player-mouth-" + i); 29 }) 30 ); 31 this.mouth[i - 1].input.hitArea = new Phaser.Geom.Rectangle(150, 195, 100, 50); 32 } 33 34 this.add.text(289, 525, "PROFILE:", { fontSize: 36, color: "#FFFFFF", fontStyle: "bold" }) 35 this.profileData = this.add.text(289, 575, "", { fontSize: 16, color: "#FFFFFF" }) 36 37 this.startButton = this.add.text(1125, 640, "START", { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 38 this.startButton.setInteractive(); 39 this.startButton.on("pointerdown", () => { 40 this.player.cosmetics.mouth = this.mouthActive.texture.key; 41 this.player.cosmetics.eyes = this.eyesActive.texture.key; 42 fetch("http://localhost:3000/profile", { 43 "method": "POST", 44 "headers": { 45 "content-type": "application/json" 46 }, 47 "body": JSON.stringify(this.player) 48 }) 49 .then(response => response.json()) 50 .then(response => { 51 this.profileData.setText(JSON.stringify(this.player)); 52 }, error => { 53 console.error(error); 54 }); 55 }, this); 56 57 }
Let me start by saying a few things first.
- The game in my example is 1280x720 and the image assets reflect this.
- The base image for the character and any cosmetic attachment image has the same resolution.
Rather than creating perfectly sized cosmetic attachment images, in my
example, I just removed the base image but left the attachment images
the same size but with transparency. Trying to figure out how to
position small images could turn into more work than I wanted. Layering
the images worked better.
With that said, we have the following:
1 this.add.image(640, 360, "background"); 2 this.add.image(800, 250, "player-base"); 3 this.eyesActive = this.add.image(800, 250, "player-eyes-1"); 4 this.mouthActive = this.add.image(800, 250, "player-mouth-1");
The initialize images are placed on the screen with varying positions.
The
eyesActive
and mouthActive
represent the initial attachments for
the character. We are only setting those to a variable because we plan
to change them later.Based on the naming convention of the images and the sizes we chose to
use, we can display the attachment options for
eyes
on the screen:1 for (let i = 1; i <= 2; i++) { 2 this.eyes.push( 3 this.add.image(75, 90 * i, "player-eyes-" + i) 4 .setCrop(150, 115, 100, 50) 5 .setScale(0.5) 6 .setInteractive() 7 .on("pointerdown", () => { 8 this.eyesActive.setTexture("player-eyes-" + i); 9 }) 10 ); 11 this.eyes[i - 1].input.hitArea = new Phaser.Geom.Rectangle(150, 115, 100, 50); 12 }
In this example, we have two different possibilities for eyes. When
pushing them into our array, we are specifying the position, but we are
also changing a few things in regards to the image. For example, we know
the images for attachments are mostly transparent. We can crop them and
scale them to show only what we want. This is for selection purposes
only, not displaying on the actual character.
We also want to respond to click events. When a click event happens, we
want the appropriate image to be used as the active image. However,
because we scaled and cropped our image, we need to redefine the
hitArea
, which is the area that should be clickable on the image.Doing most of these steps is only a requirement if you are using images
of the same size and planning to layer them like I am.
We can reproduce our steps for the mouth options:
1 for (let i = 1; i <= 2; i++) { 2 this.mouth.push( 3 this.add.image(215, 90 * i - 40, "player-mouth-" + i) 4 .setCrop(150, 195, 100, 50) 5 .setScale(0.5) 6 .setInteractive() 7 .on("pointerdown", () => { 8 this.mouthActive.setTexture("player-mouth-" + i); 9 }) 10 ); 11 this.mouth[i - 1].input.hitArea = new Phaser.Geom.Rectangle(150, 195, 100, 50); 12 }
The steps are the same, but we are using different positions.
Remember, these two steps are for defining the thumbnails of our images
and specifying a click region for them. While the active image is what
we show on the character, the lead up is related to the thumbnails.
With the thumbnails out of the way, we can add some placeholder text of
what profile we want to send:
1 this.add.text(289, 525, "PROFILE:", { fontSize: 36, color: "#FFFFFF", fontStyle: "bold" }) 2 this.profileData = this.add.text(289, 575, "", { fontSize: 16, color: "#FFFFFF" })
The goal with this text is to only show what our
player
object looks
like on the screen. This will give us a good idea of what was sent to
our MongoDB database.Finally, we have the button that will submit the data to our API:
1 this.startButton = this.add.text(1125, 640, "START", { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 2 this.startButton.setInteractive(); 3 this.startButton.on("pointerdown", () => { 4 this.player.cosmetics.mouth = this.mouthActive.texture.key; 5 this.player.cosmetics.eyes = this.eyesActive.texture.key; 6 fetch("http://localhost:3000/profile", { 7 "method": "POST", 8 "headers": { 9 "content-type": "application/json" 10 }, 11 "body": JSON.stringify(this.player) 12 }) 13 .then(response => response.json()) 14 .then(response => { 15 this.profileData.setText(JSON.stringify(this.player)); 16 }, error => { 17 console.error(error); 18 }); 19 }, this);
When the button is clicked, the image reference is added to the
player
object. Using a fetch
operation, we can send the player
object to
our back end which will store it in MongoDB. Since we aren't doing any
data validation, this will succeed as long as the _id
field is unique.If you're using MongoDB Atlas and you look at the data explorer, the
documents should look something like the above image.
You just saw how to store user profiles for a Phaser game as NoSQL
documents in MongoDB. Depending on the game you're creating, the user
profile documents could be significantly more complex in comparison to
what was seen in this example. At the end of the day, you're storing
information about your game-play experience and that information could
include information regarding the user such as password, billing
information, or similar, and you're also storing information about the
game, which might include outfit customizations, item inventory, player
location, or something else game-related.
If you are interested in seeing more Phaser examples with MongoDB, check
out some of my previous
tutorials on the
subject. If you're a Unity developer, I also have some content on that
subject as well.
Got questions regarding this tutorial or MongoDB? Visit the MongoDB
Community Forums and
connect with us!