HomeLearnHow-to

Developing a Side-Scrolling Platformer Game with Unity and MongoDB Realm

Published: Jan 04, 2021

  • Atlas
  • Realm
  • C#
  • ...

By Nic Raboy

Share

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 (Nic Raboy) 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.

In this tutorial, we're going to see what went into creating a game like The Untitled Leafy Game using Unity as the game development framework and MongoDB Realm for data storage and back end.

To get a better idea of what we're going to accomplish, take a look at the following animated image:

The Untitled Leafy Game

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.

#The Requirements

There are a few requirements that must be met prior to starting this tutorial:

  • You must be using MongoDB Atlas and MongoDB Realm.
  • You must be using Unity 2020.1.8f1 or more recent.
  • 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.

Many of the assets in The Untitled Leafy Game were obtained through the Unity Asset Store. For this reason, I won't be able to share them raw in this tutorial. However, they are available for free with a Unity account.

You can follow along with this tutorial using the source material on GitHub. We won't be doing a step by step reproduction, but we'll be exploring important topics, all of which can be further seen in the project source on GitHub.

#Creating the Game Back End with MongoDB Atlas and MongoDB Realm

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:

1{
2 "_id": ObjectId("5f973c8c083f84fa6151ca54"),
3 "question_text": "MongoDB is Awesome!",
4 "problem_id": "abc123",
5 "subject_area": "general",
6 "answer": true
7}

The question_text field will be displayed within the game. We can specify which question should be placed where in the game through the problem_id field 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 answer field. The subject_area field might be valuable when creating reports at a later date.

In MongoDB Atlas, the configuration might look like the following:

MongoDB Atlas Skunkworks Collection

In the above example, documents with the proposed data model are stored in the questions collection of the game database. 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.

MongoDB Realm Skunkworks Application

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.

MongoDB Realm HTTP Service

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_question for retrieving question information based on an id value and a checkanswer for validating a sent answer with an id value.

The get_question, which should accept GET method requests, will have the following JavaScript code:

1exports = async function (payload, response) {
2
3 const { problem_id } = payload.query;
4
5 const results = await await context.services
6 .get("mongodb-atlas")
7 .db("game")
8 .collection("questions")
9 .findOne({ "problem_id": problem_id }, { problem_id : 1, question_text : 1 })
10
11 response.setBody(JSON.stringify(results));
12
13}

In the above code, if the function is executed, the query parameters are stored. We are expecting a problem_id as a query parameter in any given request. Using that information, we can do a findOne with the problem_id as the filter. Next, we can specify that we only want the problem_id and the question_text fields returned for any matched document.

The checkanswer should accept POST requests and will have the following JavaScript code:

1exports = async function (payload, response) {
2
3 const query = payload.body.text();
4 const filter = EJSON.parse(query);
5
6 const results = await context.services
7 .get("mongodb-atlas")
8 .db("game")
9 .collection("questions")
10 .findOne({ problem_id: filter.problem_id, answer: filter.answer }, { problem_id : 1, answer: 1 });
11
12 response.setBody(results ? JSON.stringify(results) : "{}");
13
14}

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_id as well as the answer rather than just the problem_id field.

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.

#Designing a Game Scene with Game Objects, Tile Pallets, and Sprites

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 Unity Asset Store, as previously mentioned in the tutorial. In particular, we're going to be using the Pixel Adventure 1 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.

If you want to catch up on some beginner Unity with MongoDB content, check out the series that I did with Adrienne Tacke.

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:

Unity Game World Hierarchy

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:

  • GameController

    • Camera
    • Grid

      • Ground
      • Boundaries
      • Traps
    • Player
    • QuestionModal

      • ModalBackground
      • QuestionText
      • Dropdown
      • SubmitButton
    • Questions

      • QuestionOne
      • QuestionTwo
    • Trophy
    • Canvas

      • Score
    • EventSystem

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
  • Animator
  • Script

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.

#Interacting with the Game Player and the Environment

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:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class Player : MonoBehaviour {
6
7 private Rigidbody2D rb2d;
8 private Animator animator;
9 private bool isGrounded;
10
11 [Range(1, 10)]
12 public float speed;
13
14 [Range(1, 10)]
15 public float jumpVelocity;
16
17 [Range(1, 5)]
18 public float fallingMultiplier;
19
20 public Score score;
21
22 void Start() {
23 rb2d = GetComponent<Rigidbody2D>();
24 animator = GetComponent<Animator>();
25 isGrounded = true;
26 }
27
28 void FixedUpdate() {
29 float horizontalMovement = Input.GetAxis("Horizontal");
30
31 if(Input.GetKey(KeyCode.Space) && isGrounded == true) {
32 rb2d.velocity += Vector2.up * jumpVelocity;
33 isGrounded = false;
34 }
35
36 if (rb2d.velocity.y < 0) {
37 rb2d.velocity += Vector2.up * Physics2D.gravity.y * (fallingMultiplier - 1) * Time.fixedDeltaTime;
38 }
39 else if (rb2d.velocity.y > 0 && !Input.GetKey(KeyCode.Space)) {
40 rb2d.velocity += Vector2.up * Physics2D.gravity.y * (fallingMultiplier - 1) * Time.fixedDeltaTime;
41 }
42
43 rb2d.velocity = new Vector2(horizontalMovement * speed, rb2d.velocity.y);
44
45 if(rb2d.position.y < -10.0f) {
46 rb2d.position = new Vector2(0.0f, 1.0f);
47 score.Reset();
48 }
49 }
50
51 private void OnCollisionEnter2D(Collision2D collision) {
52 if (collision.collider.name == "Ground" || collision.collider.name == "Platforms") {
53 isGrounded = true;
54 }
55 if(collision.collider.name == "Traps") {
56 rb2d.position = new Vector2(0.0f, 1.0f);
57 score.Reset();
58 }
59 }
60
61 void OnTriggerEnter2D(Collider2D collider) {
62 if (collider.name == "Trophy") {
63 Destroy(collider.gameObject);
64 score.BankScore();
65 GameController.NextLevel();
66 }
67 }
68
69}

The above code could be a lot to take in, so we're going to break it down starting with the variables.

1private Rigidbody2D rb2d;
2private Animator animator;
3private bool isGrounded;
4
5[Range(1, 10)]
6public float speed;
7
8[Range(1, 10)]
9public float jumpVelocity;
10
11[Range(1, 5)]
12public float fallingMultiplier;
13
14public Score score;

The rb2d variable will be used to obtain the currently added Rigidbody 2D component. Likewise, the animator variable will obtain the Animator component. We'll use isGrounded to let us know if the player is currently jumping so that way, we can't jump infinitely.

The public variables such as speed, jumpVelocity, and fallingMultiplier have 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 score variable 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.

1void Start() {
2 rb2d = GetComponent<Rigidbody2D>();
3 animator = GetComponent<Animator>();
4 isGrounded = true;
5}

On the first rendered frame, we obtain each of the components and default our isGrounded variable.

During the FixedUpdate method, which happens continuously, we can check for keyboard interaction:

1float horizontalMovement = Input.GetAxis("Horizontal");
2
3if(Input.GetKey(KeyCode.Space) && isGrounded == true) {
4 rb2d.velocity += Vector2.up * jumpVelocity;
5 isGrounded = false;
6}

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 jumpVelocity is 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 fallingMultiplier variable:

1if (rb2d.velocity.y < 0) {
2 rb2d.velocity += Vector2.up * Physics2D.gravity.y * (fallingMultiplier - 1) * Time.fixedDeltaTime;
3}
4else if (rb2d.velocity.y > 0 && !Input.GetKey(KeyCode.Space)) {
5 rb2d.velocity += Vector2.up * Physics2D.gravity.y * (fallingMultiplier - 1) * Time.fixedDeltaTime;
6}

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.

1if(rb2d.position.y < -10.0f) {
2 rb2d.position = new Vector2(0.0f, 1.0f);
3 score.Reset();
4}

If we fall off the screen, the Reset function on 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 FixedUpdate method with the following:

1rb2d.velocity = new Vector2(horizontalMovement * speed, rb2d.velocity.y);

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 OnCollisionEnter2D and OnTriggerEnter2D methods.

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.

Take the OnCollisionEnter2D method:

1private void OnCollisionEnter2D(Collision2D collision) {
2 if (collision.collider.name == "Ground" || collision.collider.name == "Platforms") {
3 isGrounded = true;
4 }
5 if(collision.collider.name == "Traps") {
6 rb2d.position = new Vector2(0.0f, 1.0f);
7 score.Reset();
8 }
9}

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.

The OnTriggerEnter2D method is a little different.

1void OnTriggerEnter2D(Collider2D collider) {
2 if (collider.name == "Trophy") {
3 Destroy(collider.gameObject);
4 score.BankScore();
5 GameController.NextLevel();
6 }
7}

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 BankScore function that we'll see soon as well as the NextLevel function 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 score variable within the Player.cs script. The score variable 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:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public static class GameData
6{
7
8 public static int totalScore;
9
10}

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 totalScore variable will represent our session score and it will be manipulated through the Score.cs file.

Within the Score.cs file, add the following:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4using UnityEngine.UI;
5
6public class Score : MonoBehaviour
7{
8
9 private Text scoreText;
10 private int score;
11
12 void Start()
13 {
14 scoreText = GetComponent<Text>();
15 this.Reset();
16 }
17
18 public void Reset() {
19 score = GameData.totalScore;
20 scoreText.text = "SCORE: " + GameData.totalScore;
21 }
22
23 public void AddPoints(int amount) {
24 score += amount;
25 scoreText.text = "SCORE: " + score;
26 }
27
28 public void BankScore() {
29 GameData.totalScore += score;
30 }
31
32}

In the above script, we have two private variables. The scoreText will reference the component attached to our game object and the score will be the running total for the particular world.

The Reset function, 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 BankScore method. We'd typically call BankScore when 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:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4using UnityEngine.SceneManagement;
5using System;
6
7public class GameController : MonoBehaviour {
8
9 private static int currentLevelIndex;
10 private static string[] levels;
11
12 void Start() {
13 levels = new string[] {
14 "LevelOne",
15 "LevelTwo"
16 };
17 currentLevelIndex = Array.IndexOf(levels, SceneManager.GetActiveScene().name);
18 }
19
20 public static void NextLevel() {
21 if(currentLevelIndex < levels.Length - 1) {
22 SceneManager.LoadScene(levels[currentLevelIndex + 1]);
23 }
24 }
25
26}

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:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class Camera : MonoBehaviour
6{
7
8 public Transform player;
9
10 void Update() {
11 transform.position = new Vector3(player.position.x + 4, transform.position.y, transform.position.z);
12 }
13}

The player variable 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.

#Making HTTP Requests from the Unity Game to MongoDB Realm

How the game interacts with the MongoDB Realm webhooks is where the fun really comes in! I explored a lot of this in a previous tutorial I wrote titled, Sending and Requesting Data from MongoDB in a Unity Game, but it is worth exploring again for the context of The Untitled Leafy Game.

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:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class DatabaseModel {
6
7 public string _id;
8 public string question_text;
9 public string problem_id;
10 public string subject_area;
11 public bool answer;
12
13 public string Stringify() {
14 return JsonUtility.ToJson(this);
15 }
16
17 public static DatabaseModel Parse(string json) {
18 return JsonUtility.FromJson<DatabaseModel>(json);
19 }
20
21}

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 JsonUtility 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:

  1. Player collides with question box
  2. Question box, which has a problem_id associated, launches the modal
  3. Question box sends an HTTP request to MongoDB Realm
  4. Question box populates the fields in the modal based on the HTTP response
  5. Question box sends an HTTP request with the player answer to MongoDB Realm
  6. 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:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4using UnityEngine.Networking;
5using System.Text;
6using UnityEngine.UI;
7
8public class Question : MonoBehaviour {
9
10 private DatabaseModel question;
11
12 public string questionId;
13 public GameObject questionModal;
14 public Score score;
15
16 private Text questionText;
17 private Dropdown dropdownAnswer;
18 private Button submitButton;
19
20 void Start() {
21 GameObject questionTextGameObject = questionModal.transform.Find("QuestionText").gameObject;
22 questionText = questionTextGameObject.GetComponent<Text>();
23 GameObject submitButtonGameObject = questionModal.transform.Find("SubmitButton").gameObject;
24 submitButton = submitButtonGameObject.GetComponent<Button>();
25 GameObject dropdownAnswerGameObject = questionModal.transform.Find("Dropdown").gameObject;
26 dropdownAnswer = dropdownAnswerGameObject.GetComponent<Dropdown>();
27 }
28
29 private void OnCollisionEnter2D(Collision2D collision) {
30 if (collision.collider.name == "Player") {
31 questionModal.SetActive(true);
32 Time.timeScale = 0;
33 StartCoroutine(GetQuestion(questionId, result => {
34 questionText.text = result.question_text;
35 submitButton.onClick.AddListener(() =>{SubmitOnClick(result, dropdownAnswer);});
36 }));
37 }
38 }
39
40 void SubmitOnClick(DatabaseModel db, Dropdown dropdownAnswer) {
41 db.answer = dropdownAnswer.value == 0;
42 StartCoroutine(CheckAnswer(db.Stringify(), result => {
43 if(result == true) {
44 score.AddPoints(1);
45 }
46 questionModal.SetActive(false);
47 Time.timeScale = 1;
48 submitButton.onClick.RemoveAllListeners();
49 }));
50 }
51
52 IEnumerator GetQuestion(string id, System.Action<DatabaseModel> callback = null)
53 {
54 using (UnityWebRequest request = UnityWebRequest.Get("https://webhooks.mongodb-realm.com/api/client/v2.0/app/skunkworks-rptwf/service/webhooks/incoming_webhook/get_question?problem_id=" + id))
55 {
56 yield return request.SendWebRequest();
57 if (request.isNetworkError || request.isHttpError) {
58 Debug.Log(request.error);
59 if(callback != null) {
60 callback.Invoke(null);
61 }
62 }
63 else {
64 if(callback != null) {
65 callback.Invoke(DatabaseModel.Parse(request.downloadHandler.text));
66 }
67 }
68 }
69 }
70
71 IEnumerator CheckAnswer(string data, System.Action<bool> callback = null) {
72 using (UnityWebRequest request = new UnityWebRequest("https://webhooks.mongodb-realm.com/api/client/v2.0/app/skunkworks-rptwf/service/webhooks/incoming_webhook/checkanswer", "POST")) {
73 request.SetRequestHeader("Content-Type", "application/json");
74 byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(data);
75 request.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw);
76 request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
77 yield return request.SendWebRequest();
78 if (request.isNetworkError || request.isHttpError) {
79 Debug.Log(request.error);
80 if(callback != null) {
81 callback.Invoke(false);
82 }
83 } else {
84 if(callback != null) {
85 callback.Invoke(request.downloadHandler.text != "{}");
86 }
87 }
88 }
89 }
90}

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:

1private DatabaseModel question;
2
3public string questionId;
4public GameObject questionModal;
5public Score score;
6
7private Text questionText;
8private Dropdown dropdownAnswer;
9private Button submitButton;

The questionId, questionModal, and score variables 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.

The questionText, dropdownAnswer, and submitButton will be obtained from the attached questionModal game object.

To obtain each of the game objects and their components, we can look at the Start method:

1void Start() {
2 GameObject questionTextGameObject = questionModal.transform.Find("QuestionText").gameObject;
3 questionText = questionTextGameObject.GetComponent<Text>();
4 GameObject submitButtonGameObject = questionModal.transform.Find("SubmitButton").gameObject;
5 submitButton = submitButtonGameObject.GetComponent<Button>();
6 GameObject dropdownAnswerGameObject = questionModal.transform.Find("Dropdown").gameObject;
7 dropdownAnswer = dropdownAnswerGameObject.GetComponent<Dropdown>();
8}

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 questionModal so 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.

1IEnumerator GetQuestion(string id, System.Action<DatabaseModel> callback = null)
2{
3 using (UnityWebRequest request = UnityWebRequest.Get("https://webhooks.mongodb-realm.com/api/client/v2.0/app/skunkworks-rptwf/service/webhooks/incoming_webhook/get_question?problem_id=" + id))
4 {
5 yield return request.SendWebRequest();
6 if (request.isNetworkError || request.isHttpError) {
7 Debug.Log(request.error);
8 if(callback != null) {
9 callback.Invoke(null);
10 }
11 }
12 else {
13 if(callback != null) {
14 callback.Invoke(DatabaseModel.Parse(request.downloadHandler.text));
15 }
16 }
17 }
18}

In the above GetQuestion method, we expect an id which will be our problem_id that is attached to the question box. We also provide a callback which will be used when we get a response from the backend. With the UnityWebRequest, we can make a request to our MongoDB Realm webhook. Upon success, the callback variable is invoked and the parsed data is returned.

You can see this in action within the OnCollisionEnter2D method.

1private void OnCollisionEnter2D(Collision2D collision) {
2 if (collision.collider.name == "Player") {
3 questionModal.SetActive(true);
4 Time.timeScale = 0;
5 StartCoroutine(GetQuestion(questionId, result => {
6 questionText.text = result.question_text;
7 submitButton.onClick.AddListener(() =>{SubmitOnClick(result, dropdownAnswer);});
8 }));
9 }
10}

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 GetQuestion from 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:

1IEnumerator CheckAnswer(string data, System.Action<bool> callback = null) {
2 using (UnityWebRequest request = new UnityWebRequest("https://webhooks.mongodb-realm.com/api/client/v2.0/app/skunkworks-rptwf/service/webhooks/incoming_webhook/checkanswer", "POST")) {
3 request.SetRequestHeader("Content-Type", "application/json");
4 byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(data);
5 request.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw);
6 request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
7 yield return request.SendWebRequest();
8 if (request.isNetworkError || request.isHttpError) {
9 Debug.Log(request.error);
10 if(callback != null) {
11 callback.Invoke(false);
12 }
13 } else {
14 if(callback != null) {
15 callback.Invoke(request.downloadHandler.text != "{}");
16 }
17 }
18 }
19}

In the CheckAnswer method, 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 callback is 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 SubmitOnClick method:

1void SubmitOnClick(DatabaseModel db, Dropdown dropdownAnswer) {
2 db.answer = dropdownAnswer.value == 0;
3 StartCoroutine(CheckAnswer(db.Stringify(), result => {
4 if(result == true) {
5 score.AddPoints(1);
6 }
7 questionModal.SetActive(false);
8 Time.timeScale = 1;
9 submitButton.onClick.RemoveAllListeners();
10 }));
11}

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 CheckAnswer through 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.

#Conclusion

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.

To play around with the project that took Barry O'Neill and myself (Nic Raboy) three days to complete, check it out on GitHub. After swapping the MongoDB Realm endpoints with your own, you'll be able to play the game.

If you're interested in getting more out of game development with MongoDB and Unity, check out a series that I'm doing with Adrienne Tacke, starting with Designing a Strategy to Develop a Game with Unity and MongoDB.

Questions? Comments? We'd love to connect with you. Join the conversation on the MongoDB Community Forums.

MongoDB Icon
  • Developer Hub
  • Documentation
  • University
  • Community Forums

© MongoDB, Inc.