Developing a Side-Scrolling Platformer Game with Unity and MongoDB Realm
Rate this tutorial
I've been a gamer since the 1990s, so 2D side-scrolling platformer games like Super Mario Bros. hold a certain place in my heart. Today, 2D games are still being created, but with the benefit of having connectivity to the internet, whether that be to store your player state information, to access new levels, or something else.
Every year, MongoDB holds an internal company-wide hackathon known as Skunkworks. During Skunkworks, teams are created and using our skills and imagination, we create something to make MongoDB better or something that uses MongoDB in a neat way. For Skunkworks 2020, I (
) teamed up with Barry O'Neill to create a side-scrolling platformer game with Unity that queries and sends data between MongoDB and the game. Internally, this project was known as The Untitled Leafy Game.
To get a better idea of what we're going to accomplish, take a look at the following animated image:
The idea behind the game is that you are a MongoDB leaf character and you traverse through the worlds to obtain your trophy. As you traverse through the worlds, you can accumulate points by answering questions about MongoDB. These questions are obtained through a remote HTTP request and the answers are validated through another HTTP request.
There are a few requirements that must be met prior to starting this tutorial:
- At least some familiarity with Node.js (Realm) and C# (Unity).
- Your own game graphic assets.
For this tutorial, MongoDB Atlas will be used to store our data and MongoDB Realm will act as our back end that the game communicates with, rather than trying to access the data directly from Atlas.
It might seem that MongoDB plays a significant role in this game, but the amount of code to make everything work is actually quite small. This is great because as a game developer, the last thing you want is to worry about fiddling with your back end and database.
It's important to understand the data model that will represent questions in the game. For this game, we're going to use the following model:
question_textfield will be displayed within the game. We can specify which question should be placed where in the game through the
problem_idfield because it will allow us to filter for the document we want. When the player selects an answer, it will be sent back to MongoDB Realm and used as a filter for the
subject_areafield might be valuable when creating reports at a later date.
In MongoDB Atlas, the configuration might look like the following:
In the above example, documents with the proposed data model are stored in the
questionscollection of the
gamedatabase. How you choose to name your collections or even the fields of your documents is up to you.
Because we'll be using MongoDB Realm rather than a self-hosted application, we need to create webhook functions to act as our back end. Create a Realm application that uses the MongoDB Atlas cluster with our data. The naming of the application does not really matter as long as it makes sense to you.
Within the MongoDB Realm dashboard, you're going to want to click on 3rd Party Services to create new webhook functions.
Add a new HTTP service and give it a name of your choosing.
We'll have the option to create new webhooks and add associated function code to them. The idea is to create two webhooks, a
get_questionfor retrieving question information based on an id value and a
checkanswerfor validating a sent answer with an id value.
In the above code, if the function is executed, the query parameters are stored. We are expecting a
problem_idas a query parameter in any given request. Using that information, we can do a
problem_idas the filter. Next, we can specify that we only want the
question_textfields returned for any matched document.
The logic between the two functions is quite similar. The difference is that this time, we are expecting a payload to be used as the filter. We are also filtering on both the
problem_idas well as the
answerrather than just the
Assuming you have questions in your database and you've deployed your webhook functions, you should be able to send HTTP requests to them for testing. As we progress through the tutorial, interacting with the questions will be done through the Unity produced game.
With the back end in place, we can start focusing on the game itself. To set expectations, we're going to be using graphic assets from the
, as previously mentioned in the tutorial. In particular, we're going to be using the
asset pack which can be obtained for free. This is in combination with some MongoDB custom graphics.
We're going to assume this is not your first time dabbling with Unity. This means that some of the topics around creating a scene won't be explored from a beginner perspective. It will save us some time and energy and get to the point.
An example of things that won't be explored include:
- Using the Palette Editor to create a world.
- Importing media and animating sprites.
The game will be composed of worlds also referred to as levels. Each world will have a camera, a player, some question boxes, and a multi-layered tilemap. Take the following image for example:
Within any given world, we have a GameController game object. The role of this object is to orchestrate the changing of scenes, something we'll explore later in the tutorial. The Camera game object is responsible for following the player position to keep everything within view.
The Grid is the parent game object to each layer of the tilemap, where in our worlds will be composed of three layers. The Ground layer will have basic colliders to prevent the player from moving through them, likewise with the Boundaries layer. The Traps layer will allow for collision detection, but won't actually apply physics. We have separate layers because we want to know when the player interacts with any of them. These layers are composed of tiles from the Pixel Adventure 1 set and they are the graphical component to our worlds.
To show text on the screen, we'll need to use a Canvas parent game object with a child game object with the Text component. This child game object is represented by the Score game object. The Canvas comes in combination with the EventSystem which we will never directly engage with.
The Trophy game object is nothing more than a sprite with an image of a trophy. We will have collision related components attached, but more on that in a moment.
Finally, we have the Questions and QuestionModal game objects, both of which contain child game objects. The Questions group has any number of sprites to represent question boxes in the game. They have the appropriate collision components and when triggered, will interact with the game objects within the QuestionModal group. Think of it this way. The player interacts with the question box. A modal or popup displays with the text, possible answers, and a submit button. Each question box will have scripts where you can define which document in the database is associated with them.
In summary, any given world scene will look like this:
The way you design your game may differ from the above, but it worked for the example that Barry and I did for the MongoDB Skunkworks project.
We know that every item in the project hierarchy is a game object. The components we add to them define what the game object actually does. Let's figure out what we need to add to make this game work.
The Player game object should have the following components:
- Sprite Renderer
- Rigidbody 2D
- Box Collider 2D
The Sprite Renderer will show the graphic of our choosing for this particular game object. The Rigidbody 2D is the physics applied to the sprite, so how much gravity should be applied and similar. The Box Collider 2D represents the region around the image where collisions should be detected. The Animator represents the animations and flow that will be assigned to the game object. The Script, which in this example we'll call Player, will control how this sprite is interacted with. We'll get to the script later in the tutorial, but really what matters is the physics and colliders applied.
The Trophy game object and each of the question box game objects will have the same components, with the exception that the rigidbody will be static and not respond to gravity and similar physics events on the question boxes and the Trophy won't have any rigidbody. They will also not be animated.
At this point, you should have an understanding of the game objects and components that should be a part of your game world scenes. What we want to do is make the game interactive by adding to the script for the player.
The Player game object should have a script associated to it. Mine is Player.cs, but yours could be different. Within this script, add the following:
The above code could be a lot to take in, so we're going to break it down starting with the variables.
rb2dvariable will be used to obtain the currently added Rigidbody 2D component. Likewise, the
animatorvariable will obtain the Animator component. We'll use
isGroundedto let us know if the player is currently jumping so that way, we can't jump infinitely.
The public variables such as
fallingMultiplierhave to do with our physics. We want to define the movement speed, how fast a jump should happen, and how fast the player should fall when finishing a jump. Finally, the
scorevariable will be used to link the Score game object to our player script. This will allow us to interact with the text in our script.
On the first rendered frame, we obtain each of the components and default our
FixedUpdatemethod, which happens continuously, we can check for keyboard interaction:
In the above code, we are checking to see if the horizontal keys are pressed. These can be defined within Unity, but default as the a and d keys or the left and right arrow keys. If the space key is pressed and the player is currently on the ground, the
jumpVelocityis applied to the rigidbody. This will cause the player to start moving up.
To remove the feeling of the player jumping on the moon, we can make use of the
We have an if / else if for the reason of long jumps and short jumps. If the velocity is less than zero, you are falling and the multiplier should be used. If you're currently mid jump and continuing to jump, but you let go of the space key, then the fall should start to happen rather than continuing to jump until the velocity reverses.
Now if you happen to fall off the screen, we need a way to reset.
If we fall off the screen, the
score, which we'll see shortly, will reset back to zero and the position of the player will be reset to the beginning of the level.
We can finish the movement of our player in the
FixedUpdatemethod with the following:
The above line takes the movement direction based on the input key, multiplies it by our defined speed, and keeps the current velocity in the y-axis. We keep the current velocity so we can move horizontally if we are jumping or not jumping.
This brings us to the
We need to know when we've ended a jump and when we've stumbled upon a trap. We can't just say a jump is over when the y-position falls below a certain value because the player may have fallen off a cliff.
If there was a collision, we can get the game object of what we collided with. The game object should be named so we should know immediately if we collided with a floor or platform or something else. If we collided with a floor or platform, reset the jump. If we collided with a trap, we can reset the position and the score.
OnTriggerEnter2Dmethod is a little different.
Remember, the Trophy won't have a rigidbody so there will be no physics. However, we want to know when our player has overlapped with the trophy. In the above function, if triggered, we will destroy the trophy which will remove it from the screen. We will also make use of the
BankScorefunction that we'll see soon as well as the
NextLevelfunction that will change our world.
As long as the tilemap layers have the correct collider components, your player should be able to move around whatever world you've decided to create. This brings us to some of the other scripts that need to be created for interaction in the Player.cs script.
We used a few functions on the
scorevariable within the Player.cs script. The
scorevariable is a reference to our Score game object which should have its own script. We'll call this the Score.cs script. However, before we get to the Score.cs script, we need to create a static class to hold our locally persistent data.
Create a GameData.cs file with the following:
Using static classes and variables is the easiest way to pass data between scenes of a Unity game. We aren't assigning this script to any game object, but it will be accessible for as long as the game is open. The
totalScorevariable will represent our session score and it will be manipulated through the Score.cs file.
Within the Score.cs file, add the following:
In the above script, we have two private variables. The
scoreTextwill reference the component attached to our game object and the
scorewill be the running total for the particular world.
Resetfunction, which we've seen already, will set the visible text on the screen to the value in our static class. We're doing this because we don't want to necessarily zero out the score on a reset. For this particular game, rather than resetting the entire score when we fail, we reset the score for the particular world, not all the worlds. This makes more sense in the
BankScoremethod. We'd typically call
BankScorewhen we progress from one world to the next. We take the current score for the world, add it to the persisted score, and then when we want to reset, our persisted score holds while the world score resets. You can design this functionality however you want.
In the Player.cs script, we've also made use of a GameController.cs script. We do this to manage switching between scenes in the game. This GameController.cs script should be attached to the GameController game object within the scene. The code behind the script should look like the following:
So why even create a script for switching scenes when it isn't particularly difficult? There are a few reasons:
1. We don't want to manage scene switching in the Player.cs script to reduce cruft code. 2. We want to define world progression while being cautious that other scenes such as menus could exist.
With that said, when the first frame renders, we could define every scene that is a level or world. While we don't explore it here, we could also define every scene that is a menu or similar. When we want to progress to the next level, we can just iterate through the level array, all of which is managed by this scene manager.
Knowing what we know now, if we had set everything up correctly and tried to move our player around, we'd likely move off the screen. We need the camera to follow the player and this can be done in another script.
The Camera.cs script, which should be attached to the Camera game object, should have the following C# code:
playervariable should represent the Player game object, defined in the UI that Unity offers. It can really be any game object, but because we want to have the camera follow the player, it should probably be the Player game object that has the movement scripts. On every frame, the camera position is set to the player position with a small offset.
Everything we've seen up until now is responsible for player interaction. We can traverse a world, collide with the environment, and keep score.
Before we get into the sending and receiving of data, we need to create a data model within Unity that roughly matches what we see in MongoDB. Create a DatabaseModel.cs script with the following C# code:
The above script is not one that we plan to add to a game object. We'll be able to instantiate it from any script. Notice each of the public variables and how they are named based on the fields that we're using within MongoDB. Unity offers a
class that allows us to take public variables and either convert them into a JSON string or parse a JSON string and load the data into our public variables. It's very convenient, but the public variables need to match to be effective.
The process of game to MongoDB interaction is going to be as follows:
- Player collides with question box
- Question box, which has a
problem_idassociated, launches the modal
- Question box sends an HTTP request to MongoDB Realm
- Question box populates the fields in the modal based on the HTTP response
- Question box sends an HTTP request with the player answer to MongoDB Realm
- The modal closes and the game continues
With those chain of events in mind, we can start making this happen. Take a Question.cs script that would exist on any particular question box game object:
Of the scripts that exist in the project, this is probably the most complex. It isn't complex because of the MongoDB interaction. It is just complex based on how questions are integrated into the game.
Let's break it down starting with the variables:
scorevariables are assigned through the UI inspector in Unity. This allows us to give each question box a unique id and give each question box the same modal to use and score widget. If we wanted, the modal and score items could be different, but it's best to recycle game objects for performance reasons.
submitButtonwill be obtained from the attached
To obtain each of the game objects and their components, we can look at the
Remember, game objects don't mean a whole lot to us. We need to get the components that exist on each game object. We have the attached
questionModalso we can use Unity to find the child game objects that we need and their components.
Before we explore how the HTTP requests come together with the rest of the script, we should explore how these requests are made in general.
In the above
GetQuestionmethod, we expect an
idwhich will be our
problem_idthat is attached to the question box. We also provide a
callbackwhich will be used when we get a response from the backend. With the
, we can make a request to our MongoDB Realm webhook. Upon success, the
callbackvariable is invoked and the parsed data is returned.
You can see this in action within the
When a collision happens, we see if the Player game object is what collided. If true, then we set the modal to active so it displays, alter the time scale so the game pauses, and then execute the
GetQuestionfrom within a Unity coroutine. When we get a result for that particular
problem_id, we set the text within the modal and add a special click listener to the button. We want the button to use the correct information from this particular instance of the question box. Remember, the modal is shared for all questions in this example, so it is important that the correct listener is used.
So we displayed the question information in the modal. Now we need to submit it. The HTTP request is slightly different:
CheckAnswermethod, we do another
UnityWebRequest, this time a POST request. We encode the JSON string which is our data and we send it to our MongoDB Realm webhook. The result for the
callbackis either going to be a true or false depending on if the response is an empty object or not.
We can see this in action through the
Dropdowns in Unity are numeric, so we need to figure out if it is true or false. Once we have this information, we can execute the
CheckAnswerthrough a coroutine, sending the document information with our user defined answer. If the response is true, we add to the score. Regardless, we hide the modal, reset the time scale, and remove the listener on the button.
While we didn't see the step by step process towards reproducing a side-scrolling platformer game like the MongoDB Skunkworks project, The Untitled Leafy Game, we did walk through each of the components that went into it. These components consisted of designing a scene for a possible game world, adding player logic, score keeping logic, and HTTP request logic.