When it comes to game development, an often forgotten component comes in the form of a database for storing gameplay information. The database can contribute to numerous roles, such as storing user profile information, game state, and so much more.
In fact, I created a previous tutorial titled Creating a Multiplayer Drawing Game with Phaser and MongoDB. In this drawing example, every brushstroke made was stored in MongoDB.
In this tutorial, we're going to look at a different data component for a game. We're going to explore leaderboards and some of the neat things you can do with them. Like my other tutorial, we'll be using Phaser and JavaScript.
To get an idea of what we want to accomplish, take a look at the following animated image:

The above game has three different screens, which are referred to as scenes in Phaser. The first screen (first few seconds of gif) accepts user input for a username and also gathers geolocation information about the player. The second screen is where you actually play the game and attempt to accumulate points (by collecting leaves) while avoiding bombs! Finally, the third screen is where your score and location information is submitted. You'll also see the all-time top scores as well as the top scores near your location, all of which can be queried easily using MongoDB!
#The Requirements
There aren't many requirements that must be met in order to be successful with this tutorial. Here's what you'll need:
- A MongoDB Atlas cluster with proper user role and network configuration
- Node.js 12+
For this example, MongoDB Atlas will store all of our leaderboard information and Node.js will power our backend API. The Phaser game doesn't have any real dependency beyond having something available to serve the project. I use serve to accomplish this, but you can use whatever you're comfortable with.
#Building the Backend for the Leaderboard with Node.js and MongoDB
Before we jump into the game development side of things (with Phaser), we should take care of our backend API. This backend API will be responsible for the direct interaction with our database. It will accept requests from our game to store data as well as requests to fetch data.
Create a new project directory on your computer and from within that directory, execute the following commands:
1 npm init -y 2 npm install express body-parser cors mongodb --save
The above commands will create a new package.json file and install the project dependencies for our backend. We'll be using Express to create our API and the MongoDB Node.js driver for communicating with the database.
For the backend, we'll add all of our code to a main.js file. Create it in your project directory and add the following JavaScript:
1 const { MongoClient, ObjectID } = require("mongodb"); 2 const Express = require("express"); 3 const Cors = require("cors"); 4 const BodyParser = require("body-parser"); 5 const { request } = require("express"); 6 7 const client = new MongoClient(process.env["ATLAS_URI"]); 8 const server = Express(); 9 10 server.use(BodyParser.json()); 11 server.use(BodyParser.urlencoded({ extended: true })); 12 server.use(Cors()); 13 14 var collection; 15 16 server.post("/create", async (request, response) => {}); 17 server.get("/get", async (request, response) => {}); 18 server.get("/getNearLocation", async (request, response) => {}); 19 20 server.listen("3000", async () => { 21 try { 22 await client.connect(); 23 collection = client.db("gamedev").collection("scores"); 24 collection.createIndex({ "location": "2dsphere" }); 25 } catch (e) { 26 console.error(e); 27 } 28 });
The above JavaScript has a lot of boilerplate code that I won't get into the details on. If you'd like to learn how to connect to MongoDB with Node.js, check out Lauren Schaefer's getting started tutorial on the subject.
There are a few lines that I want to bring attention to, starting with
the creation of the MongoClient
object:
1 const client = new MongoClient(process.env["ATLAS_URI"]);
In this example, ATLAS_URI
is an environment variable on my computer
and Node.js is reading from that variable.
For context, the variable looks something like this:
1 mongodb+srv://<username>:<password>@cluster0-yyarb.mongodb.net/<database>?retryWrites=true&w=majority
Regardless on how you wish to create the MongoClient
, make sure your
MongoDB Atlas connection URL contains the correct username and password
information that you defined within the MongoDB Atlas dashboard.
The next thing I want to bring attention to is in the following two lines:
1 collection = client.db("gamedev").collection("scores"); 2 collection.createIndex({ "location": "2dsphere" });
In this example, my database is gamedev
and my collection is
scores
. If you want to use your own naming, just swap what I have
with your own database and collection names. Also, since we plan to use geospatial
queries, we'll need a geospatial index. That's where the``createIndex`` command comes in. When we launch our backend, the
index will be created for us on the location
field of our documents, just as we've specified in our command.
We don't need any documents created at this point, but when documents get inserted, they'll look something like this:
1 { 2 "_id": "23abcd87ef", 3 "username": "nraboy", 4 "score": 35, 5 "location": { 6 "type": "Point", 7 "coordinates": [ -121, 37 ] 8 } 9 }
The location
field is a special, formatted GeoJSON compliant object.
The formatting is important when it comes to the geospatial queries and
the index itself.
With the base of our backend created, let's start creating each of the endpoint functions.
Since we don't have any data to work with, let's start with the
create
endpoint function:
1 server.post("/create", async (request, response) => { 2 try { 3 let result = await collection.insertOne( 4 { 5 "username": request.body.username, 6 "score": request.body.score, 7 "location": request.body.location 8 } 9 ); 10 response.send({ "_id": result.insertedId }); 11 } catch (e) { 12 response.status(500).send({ message: e.message }); 13 } 14 });
When the client makes a POST request, we take the username
,
score
, and location
from the payload and insert it into MongoDB
as a new document. The resulting _id
will be returned to the user
when successful. Data validation is out of the scope of this tutorial,
but it is important to note that we're not validating any of the data
coming from the user.
Now that we can create scores, we'll need a way to query for them. Looking at
the get
endpoint, we can do the following:
1 server.get("/get", async (request, response) => { 2 try { 3 let result = await collection.find({}).sort({ score: -1 }).limit(3).toArray(); 4 response.send(result); 5 } catch (e) { 6 response.status(500).send({ message: e.message }); 7 } 8 });
In the above code, we are using the find
method on our collection with no
filter. This means we'll be attempting to retrieve all documents in the
collection. However, we're also using a sort
and limit
, which says that we only
want three documents in descending order.
With this endpoint configured this way, we can treat it as a global function that gets the top three scores from any location. Perfect for a leaderboard!
Now we can narrow down our results. Let's take a look at the
getNearLocation
function:
1 server.get("/getNearLocation", async (request, response) => { 2 try { 3 let result = await collection.find({ 4 "location": { 5 "$near": { 6 "$geometry": { 7 "type": "Point", 8 "coordinates": [ 9 parseFloat(request.query.longitude), 10 parseFloat(request.query.latitude) 11 ] 12 }, 13 "$maxDistance": 25000 14 } 15 } 16 }).sort({ score: -1 }).limit(3).toArray(); 17 response.send(result); 18 } catch (e) { 19 response.status(500).send({ message: e.message }); 20 } 21 });
Here, we also use the``find`` method, but utilize a geospatial
query as our filter using the $near
operator. When we pass a latitude and longitude
position as query parameters in the request, we build a geospatial
query that returns any document with a location that's within 25,000 meters of the
provided position. You can play around with the numbers to get the
results that you need.
The backend should be ready to go. Make sure you are running the Node.js application before you try to play the game that we create in the next step.
#Creating and Configuring a New Phaser Project with Geolocation
When we create our game we're going to want to create a new project directory. On your computer create a new project directory and in it, create an index.html file with the following HTML markup:
1 2 <html> 3 <head> 4 <script src="//cdn.jsdelivr.net/npm/phaser@3.24.1/dist/phaser.min.js"></script> 5 <script src="information-scene.js"></script> 6 <script src="main-scene.js"></script> 7 <script src="gameover-scene.js"></script> 8 </head> 9 <body> 10 <div id="game"></div> 11 <script>
const phaserConfig = {
type: Phaser.AUTO,
parent: "game",
width: 1280,
height: 720,
dom: {
createContainer: true
},
physics: {
default: "arcade",
arcade: {
debug: false
}
},
scene: []
};
const game = new Phaser.Game(phaserConfig);
</script> 12 </body> 13 </html>
The above code shouldn't run, but it is the starting point to our Phaser game. Let's break it down.
Within the <head>
you'll notice the following <script>
tags:
1 <head> 2 <script src="//cdn.jsdelivr.net/npm/phaser@3.24.1/dist/phaser.min.js"></script> 3 <script src="information-scene.js"></script> 4 <script src="main-scene.js"></script> 5 <script src="gameover-scene.js"></script> 6 </head>
The first JavaScript file is the Phaser framework. The other three are files that we'll be creating. You can create each of the remaining three files now or wait until we get to that step in the tutorial.
At this point in the tutorial, the most important chunk of information is in here:
1 const phaserConfig = { 2 type: Phaser.AUTO, 3 parent: "game", 4 width: 1280, 5 height: 720, 6 dom: { 7 createContainer: true 8 }, 9 physics: { 10 default: "arcade", 11 arcade: { 12 debug: false 13 } 14 }, 15 scene: [] 16 };
In the above configuration, we are defining the game canvas, but we are also enabling the physics engine as well as DOM element embedding.
The DOM element embedding allows us to embed a user input field into
our game so users can enter their name. The physics engine
allows us to handle collisions between
the player and the reward as well as the player and the obstacle. There
are numerous physics engines available with Phaser, but we'll use the arcade
physics option as it's the easiest to use.
Because we plan to keep scores for players based on their name and
location, we'll need to enable location tracking. Within the
<script>
tag that contains the phaserConfig
, modify it to the
following:
1 <script>
// phaserConfig ...
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
if(!position.coords || !position.coords.longitude) {
position.coords.latitude = 0;
position.coords.longitude = 0;
}
const game = new Phaser.Game(phaserConfig);
});
} else {
console.error("Geolocation is not supported by this browser!");
}
</script>
The above code will leverage the location tracking of the web browser. When the game starts, the browser will prompt the user to enable location tracking. If the location cannot be determined, zero values will be used. If location tracking is not available in the browser, an error will be shown in the logs and the game will fail to configure.
As of right now nothing is done with the location, but after the user accepts, the game will start. However, since we don't have any scenes created yet, nothing will happen.
It's important to note that between getting the location and starting the game, it could take a few seconds to a few minutes. This is dependent on how quickly the browser can detect your location.
With the configuration out of the way, let's create the first scene that the player sees.
#Design a Game Scene for User Input with HTML and JavaScript
The first scene the player sees will prompt them to enter a username. This username will be sent to our backend and stored in MongoDB with a score.

If you haven't already, create an information-scene.js file and include the following code:
1 var InformationScene = new Phaser.Class({ 2 Extends: Phaser.Scene, 3 initialize: function () { 4 Phaser.Scene.call(this, { key: "InformationScene" }); 5 }, 6 init: function (data) { }, 7 preload: function () { }, 8 create: async function () { }, 9 update: function () { } 10 });
The above class represents our initial scene. In it, you'll see the four lifecycle events that Phaser uses to compose a scene. Because this scene takes user input, we need to create another HTML file that contains our form.
Create a form.html file within the game project and add the following HTML markup:
1 2 <html> 3 <head> 4 <style>
#input-form {
padding: 15px;
background-color: #CCCCCC;
}
#input-form input {
padding: 10px;
font-size: 20px;
width: 400px;
}
</style> 5 </head> 6 <body> 7 <div id="input-form"> 8 <input type="text" name="username" placeholder="Enter a Name" /> 9 </div> 10 </body> 11 </html>
There's nothing particularly fancy happening in the above HTML. However,
take note of the name
attribute on the <input>
tag. We'll be
referencing it later within our information-scene.js file.
Jumping back into the information-scene.js file, we need to load the
HTML file that we just created in the preload
function:
1 preload: function () { 2 this.load.html("form", "form.html"); 3 },
With the HTML form loaded in our scene, we can now work towards displaying it and
capturing any data entered into it. This can be done from the create
function like so:
1 create: async function () { 2 3 this.usernameInput = this.add.dom(640, 360).createFromCache("form"); 4 5 this.returnKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER); 6 7 this.returnKey.on("down", event => { 8 let username = this.usernameInput.getChildByName("username"); 9 if(username.value != "") { 10 // Switch scene ... 11 } 12 }) 13 14 },
In the preload
function, we've referenced the HTML file as form
,
which will be added as a DOM element on the Phaser canvas. We want to
listen for events on a particular keystroke, in this case the enter key,
and when the enter key is pressed, we want to get the data from the
username
element. Remember the name
attribute on the <input>
tag? That is the value we're using in the getChildByName
function.
At this point, we technically have a working scene even though nothing happens with the user input. Let's edit the index.html file so we can switch to it:
1 <script>
const phaserConfig = {
type: Phaser.AUTO,
parent: "game",
width: 1280,
height: 720,
dom: {
createContainer: true
},
physics: {
default: "arcade",
arcade: {
debug: false
}
},
scene: [InformationScene]
};
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
if(!position.coords || !position.coords.longitude) {
position.coords.latitude = 0;
position.coords.longitude = 0;
}
const game = new Phaser.Game(phaserConfig);
game.scene.start("InformationScene", {
location: {
type: "Point",
coordinates: [
parseFloat(position.coords.longitude.toFixed(1)),
parseFloat(position.coords.latitude.toFixed(1))
]
}
})
});
} else {
console.error("Geolocation is not supported by this browser!");
}
</script>
If you're wondering what changed, first take a look at the scene
field of the phaserConfig
object. Notice that we've included the
InformationScene
class to the array. Next, take a look at what
happens after we get the geolocation information from the browser:
1 game.scene.start("InformationScene", { 2 location: { 3 type: "Point", 4 coordinates: [ 5 parseFloat(position.coords.longitude.toFixed(1)), 6 parseFloat(position.coords.latitude.toFixed(1)) 7 ] 8 } 9 })
These changes let us start the InformationScene
scene and
pass the location information we receive from the browser into it. To maintain my
privacy for the example, I'm setting the decimal precision of the
latitude and longitude to only a single decimal. This way, it shows my
general location, but not exactly where I live.
The location information is formatted as appropriate GeoJSON since that is what MongoDB will depend on later.
So if we're passing information into our scene, how do we make use of it?
Open the information-scene.js file and change the init
function
to look like this:
1 init: function (data) { 2 this.location = data.location; 3 },
We're taking the data that was passed and are storing it in a local variable to the class. When we switch from this scene to another scene in the future, we'll pass the variable again.
Before we start working on the next scene, let's display the location
information underneath the text input. Within the create
function of
the information-scene.js file, add the following:
1 this.locationText = this.add.text( 2 640, 3 425, 4 `[${this.location.coordinates[1]}, ${this.location.coordinates[0]}]`, 5 { 6 fontSize: 20 7 } 8 ).setOrigin(0.5);
The above code renders whatever is in the location variable. It should be centered below the input field in this particular scene of the game.
Even though the next scene doesn't exist yet, let's get the switching logic in place. Let's change the logic that happens when the enter key is pressed after typing a username:
1 this.returnKey.on("down", event => { 2 let username = this.usernameInput.getChildByName("username"); 3 if(username.value != "") { 4 this.scene.start("MainScene", { username: username.value, score: 0, location: this.location }); 5 } 6 })
We'll be calling the next scene MainScene
and we're going to pass into it
the username that the user provided, the location from the browser, and
an initial score.
#Developing the Game Logic for an Interactive Gameplay Experience
We have a username and some location data to work with. Now it's time to create the game that the user can actually play.

Create a main-scene.js file in your project if you haven't already and include the following code:
1 var MainScene = new Phaser.Class({ 2 Extends: Phaser.Scene, 3 initialize: function() { 4 Phaser.Scene.call(this, { key: "MainScene" }); 5 }, 6 init: function(data) { 7 this.username = data.username; 8 this.score = data.score; 9 this.location = data.location; 10 }, 11 preload: function() { }, 12 create: function() { }, 13 update: function() { } 14 });
You'll notice that we are accepting the data passed in from the previous
scene in the init
function. Before we start modifying the other
lifecycle functions, let's add this scene to the phaserConfig
found
in the index.html file:
1 scene: [InformationScene, MainScene]
Since the MainScene
represents our gameplay scene, we need to
preload our game assets.
You can download my assets below or use your own images.



The actual images are not very important as long as something exists. Depending on the resolution of your images, you may need to change the scaling that we do later in the tutorial.
With the image files in your project, change the preload
function of
the main-scene.js file to look like the following:
1 preload: function() { 2 this.load.image("leaf", "leaf.png"); 3 this.load.image("bomb", "bomb.png"); 4 this.load.image("box", "box.png"); 5 },
We're only ever going to have a single box to represent our player, but
we're going to have many of the leaf
and bomb
game objects. This
means that we'll need to create two object pools and one single sprite
for the player.
If you're new to the concept of object pools, they are common when it comes to game development. The idea behind them is that instead of creating and destroying game objects as needed, which is bad for performance, a specific number of objects are created up front and these objects exist inactive and invisible until needed. When the object is no longer needed, it is deactivated and made invisible, hence going back into the pool to be used again in the future.
In the create
function of the main-scene.js file, add the
following JavaScript code:
1 create: function() { 2 this.player = this.physics.add.sprite(640, 650, "box"); 3 this.player.setScale(0.25); 4 this.player.setDepth(1); 5 this.player.setData("score", this.score); 6 this.player.setData("username", this.username); 7 8 this.leafGroup = this.physics.add.group({ 9 defaultKey: "leaf", 10 maxSize: 30, 11 visible: false, 12 active: false 13 }); 14 15 this.bombGroup = this.physics.add.group({ 16 defaultKey: "bomb", 17 maxSize: 30, 18 visible: false, 19 active: false 20 }); 21 },
Remember the score and username information that was passed from the previous scene? We're attaching this data to the player (lines 5-6).
For the leafGroup
and bombGroup
, we are creating object pools.
These object pools have thirty objects each and are inactive and
invisible by default. This is convenient as we don't want to display or activate them
until we're ready to use them.
The score is being tracked on the player, but it's probably a good idea
to show it as well. Within the create
function of the
main-scene.js function, add the following:
1 create: function() { 2 3 // Sprite and object pool creation logic ... 4 5 this.scoreText = this.add.text(10, 10, "SCORE: 0", { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 6 this.scoreText.setDepth(1); 7 8 }
In the above code, we are initializing our text to be rendered with specific formatting. We're going to change it later as the score increases.
Even though we're not currently using our object pools, let's define some collision logic for when they do collide with our player.
Within the create
function of the main-scene.js file, add the
following:
1 create: function() { 2 3 // Sprite and object pool creation logic ... 4 // Render text ... 5 6 this.physics.add.collider(this.player, this.leafGroup, (player, leaf) => { 7 if (leaf.active) { 8 this.score = player.getData("score"); 9 this.score++; 10 player.setData("score", this.score); 11 this.scoreText.setText("SCORE: " + this.score); 12 this.leafGroup.killAndHide(leaf); 13 } 14 }); 15 16 this.physics.add.collider(this.player, this.bombGroup, (player, bomb) => { 17 if (bomb.active) { 18 this.bombGroup.killAndHide(bomb); 19 // Change scenes ... 20 } 21 }); 22 23 }
We have two colliders in the above code. One with logic to determine what happens when a leaf
touches the player and one for when a bomb touches the player. When the
leaf touches the player, we need to first make sure the leaf was active. When an object is active, certain game and scene logic is able to be applied.
If leaf was active, we need to get the current score from the player
object, increase it, set the new score
to the player
object and update the rendered text. We also need to add the
leaf back to the pool so it can be used again.
When the bomb touches the player, we need to make sure the bomb is active and if it is, add it back to the pool and change the scene. We'll work on the logic for changing the scene soon.
Now is a good time to pull objects from both of our object pools. Within
the create
function of the main-scene.js file, add the following
JavaScript code:
1 create: function() { 2 3 // Sprite and object pool creation logic ... 4 // Render text ... 5 // Collider logic ... 6 7 this.time.addEvent({ 8 delay: 250, 9 loop: true, 10 callback: () => { 11 let leafPositionX = Math.floor(Math.random() * 1280); 12 let bombPositionX = Math.floor(Math.random() * 1280); 13 this.leafGroup.get(leafPositionX, 0) 14 .setScale(0.1) 15 .setActive(true) 16 .setVisible(true); 17 this.bombGroup.get(bombPositionX, 0) 18 .setScale(0.1) 19 .setActive(true) 20 .setVisible(true); 21 } 22 }); 23 24 }
In the above code, we are creating a repeating timer. Every time the timer triggers, we pull a leaf and a bomb from the object pools and place them at a random position on the x-axis. We also activate that particular object and make it visible.
Don't try to pull more objects than exist in the pool, otherwise you'll get errors if the pool is empty.
The timer is pulling objects, but those objects are not yet moving. We
need to move them in the update
function of the main-scene.js
file. Change the function to look like the following:
1 update: function() { 2 this.leafGroup.incY(6); 3 this.leafGroup.getChildren().forEach(leaf => { 4 if (leaf.y > 800) { 5 this.leafGroup.killAndHide(leaf); 6 } 7 }); 8 this.bombGroup.incY(6); 9 this.bombGroup.getChildren().forEach(bomb => { 10 if (bomb.y > 800) { 11 this.bombGroup.killAndHide(bomb); 12 } 13 }); 14 }
For both the leafGroup
and the bombGroup
, we are increasing the
position on the y-axis. We are doing this for every object in those
object pools. Even though we're changing the position of the entire
pool, you'll only see and interact with the active and visible objects.
To prevent us from running out of objects in the pool, we can loop through each pool and see if any objects have moved beyond the screen. If they have, add them back into the pool.
So as of right now we have objects falling down the screen in our scene. If our player touches any of the leaf objects our score increases, otherwise the bombs will end the scene. The problem is that we can't actually control our player yet. This is an easy fix though.
Within the update
function of the main-scene.js file, add the
following:
1 update: function() { 2 3 // Object pool movement logic ... 4 5 if (this.input.activePointer.isDown) { 6 this.player.x = this.input.activePointer.position.x; 7 } 8 9 }
Now when the pointer is down, whether it be on mobile or desktop, the x-axis position of the player will be updated to wherever the pointer is. For this particular game we won't bother updating the y-axis position.
With the exception of changing from this current scene to the next scene, we have a playable game.
Let's jump back into the collider logic for the bomb:
1 this.physics.add.collider(this.player, this.bombGroup, (player, bomb) => { 2 if (bomb.active) { 3 this.bombGroup.killAndHide(bomb); 4 this.scene.start("GameOverScene", { 5 "username": this.player.getData("username"), 6 "score": this.player.getData("score"), 7 "location": this.location 8 }); 9 } 10 });
Even though we haven't created a GameOverScene
scene, we've assumed
that we have. So if we collide with a bomb, the GameOverScene
will
start and we'll pass the username, score, and location information to
the next scene.
#Creating, Querying, and Displaying Leaderboard Information Stored in MongoDB
The final scene is where we actually include MongoDB into our game. We've defined our information, played our game, and now we need to send it to MongoDB for storage.

If you haven't already, create a gameover-scene.js file in your project directory with the following code:
1 var GameOverScene = new Phaser.Class({ 2 Extends: Phaser.Scene, 3 initialize: function() { 4 Phaser.Scene.call(this, { key: "GameOverScene" }); 5 }, 6 init: function(data) { 7 this.player = data; 8 }, 9 preload: function() {}, 10 create: async function() {}, 11 update: function() {} 12 });
The above class should look familiar as it was used as the basis for the
previous two classes. In the init
function we accept the username,
score, and location data from the previous scene. Before we start
defining our scene functionality, let's add the scene to the
index.html file in the phaserConfig
object:
1 scene: [InformationScene, MainScene, GameOverScene]
Since we do not have any game assets, we don't need to use the
preload
function within the gameover-scene.js file. Instead,
let's take a look at the create
function:
1 create: async function() { 2 try { 3 if (this.player.username && this.player.score) { 4 await fetch("http://localhost:3000/create", { 5 "method": "POST", 6 "headers": { 7 "content-type": "application/json" 8 }, 9 "body": JSON.stringify(this.player) 10 }); 11 } 12 13 this.globalScores = await fetch("http://localhost:3000/get") 14 .then(response => response.json()); 15 16 this.nearbyScores = await fetch(`http://localhost:3000/getNearLocation?latitude=${this.player.location.coordinates[1]}&longitude=${this.player.location.coordinates[0]}`) 17 .then(response => response.json()); 18 } catch (e) { 19 console.error(e); 20 } 21 22 },
In the above create
function, we're doing three requests. First we
are taking the player information provided in the previous scene and
we're sending it to our backend via the create
endpoint. After we
send our score data, we do two requests, the first for the top three
global scores, and the second for the top three scores near my latitude
and longitude.
The next step is to render the results from these requests on the screen as text.
1 create: async function() { 2 try { 3 4 // REST API logic ... 5 6 this.add.text(10, 100, "GLOBAL HIGH SCORES", { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 7 this.add.text(600, 100, "NEARBY HIGH SCORES", { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 8 9 this.add.text(10, 10, "YOUR SCORE: " + this.player.score, { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 10 11 for(let i = 0; i < this.globalScores.length; i++) { 12 this.add.text(10, 100 * (i + 2), `${this.globalScores[i].username}: ${this.globalScores[i].score}`, { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 13 } 14 15 for(let i = 0; i < this.nearbyScores.length; i++) { 16 this.add.text(600, 100 * (i + 2), `${this.nearbyScores[i].username}: ${this.nearbyScores[i].score}`, { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 17 } 18 19 this.retryButton = this.add.text(1125, 640, "RETRY", { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 20 this.retryButton.setInteractive(); 21 22 this.retryButton.on("pointerdown", () => { 23 this.scene.start("MainScene", { username: this.player.username, score: 0, location: this.player.location }); 24 }, this); 25 26 } catch (e) { 27 console.error(e); 28 } 29 },
The above code might look messy, but the reality is that we're just rendering text to the screen. We're looping through both of the scores results and rending the results and we're also rendering the current score.
To replay the game, we create a text that is clickable:
1 this.retryButton = this.add.text(1125, 640, "RETRY", { fontSize: 40, color: '#000000', fontStyle: "bold", backgroundColor: "#FFFFFF", padding: 10 }); 2 this.retryButton.setInteractive(); 3 4 this.retryButton.on("pointerdown", () => { 5 this.scene.start("MainScene", { username: this.player.username, score: 0, location: this.player.location }); 6 }, this);
When clicking on the button, the MainScene
is started and the
current player information is sent.
#Conclusion
You just saw how to work with leaderboard information using MongoDB within a Phaser game. In this particular example we saw two different types of leaderboards, one being global and one being geospatial. Another possibility that we didn't explore could be in the realm of platform such as mobile or desktop.
If you want to see another gaming example with MongoDB, check out my previous tutorial titled Creating a Multiplayer Drawing Game with Phaser and MongoDB.