Interested in speaking at MongoDB World 2022? Click here to become a speaker.
HomeLearnHow-toBuilding a Space Shooter Game that Syncs with Unity and MongoDB Realm

Building a Space Shooter Game that Syncs with Unity and MongoDB Realm

Updated: Aug 25, 2021 |

Published: Aug 12, 2021

  • Realm
  • Atlas
  • C#
  • ...

By Nic Raboy

Rate this article

When developing a game, in most circumstances you're going to need to store some kind of data. It could be the score, it could be player inventory, it could be where they are located on a map. The possibilities are endless and it's more heavily dependent on the type of game.

Need to sync that data between devices and your remote infrastructure? That is a whole different scenario.

If you managed to catch MongoDB .Live 2021, you'll be familiar that the first stable release of the MongoDB Realm SDK for Unity was made available. This means that you can use Realm in your Unity game to store and sync data with only a few lines of code.

In this tutorial, we're going to build a nifty game that explores some storage and syncing use-cases.

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

MongoDB Realm Space Shooter Example
MongoDB Realm Space Shooter Example

In the above example, we have a space shooter style game. Waves of enemies are coming at you and as you defeat them your score increases. In addition to keeping track of score, the player has a set of enabled blasters. What you don't see in the above example is what's happening behind the scenes. The score is synced to and from the cloud and likewise are the blasters.

#The Requirements

There are a lot of moving pieces for this particular gaming example. To be successful with this tutorial, you'll need to have the following ready to go:

  • Unity 2021.2.0b3 or newer
  • A MongoDB Atlas M0 cluster or better
  • A Realm web application pointed at the Atlas cluster
  • Game media assets

This is heavily a Unity example. While older or newer versions of Unity might work, I was personally using 2021.2.0b3 when I developed it. You can check to see what version of Unity is available to you using the Unity Hub software.

Because we are going to be introducing a synchronization feature to the game, we're going to need an Atlas cluster as well as a Realm application. Both of these can be configured for free with the MongoDB Cloud. Don't worry about the finer details of the configuration because we'll get to those as we progress in the tutorial.

As much as I'd like to take credit for the space shooter assets used within this game, I can't. I actually downloaded them from the Unity Asset Store. Feel free to download what I used or create your own.

If you're looking for a basic getting started tutorial for Unity with Realm, check out my previous tutorial on the subject.

#Designing the Scenes and Interfaces for the Unity Game

The game we're about to build is not a small and quick project. There will be many game objects and a few scenes that we have to configure, but none of it is particularly difficult.

To get an idea of what we need to create, make note of the following breakdown:

  • LoginScene

    • Camera
    • LoginController
    • RealmController
    • Canvas

      • UsernameField
      • PasswordField
      • LoginButton
  • MainScene

    • GameController
    • RealmController
    • Background
    • Player
    • Canvas

      • HighScoreText
      • ScoreText
    • BlasterEnabled
    • SparkBlasterEnabled
    • CrossBlasterEnabled
    • Blaster
    • CrossBlast
    • Enemy
    • SparkBlast

The above list represents our two scenes with each of the components that live within the scene.

Let's start by configuring the LoginScene with each of the components. Don't worry, we'll explore the logic side of things for this scene later.

Within the Unity IDE, create a LoginScene and within the Hierarchy choose to create a new UI -> Input Field. You'll need to do this twice because this is how we're going to create the UsernameField and the PasswordField that we defined in the list above. You're also going to want to create a UI -> Button which will represent our LoginButton to submit the form.

For each of the UI game objects, position them on the screen how you want them. Mine looks like the following:

Space Shooter Login Scene
Space Shooter Login Scene

Within the Hierarchy of your scene, create two empty game objects. The first game object, LoginController, will eventually hold a script for managing the user input and interactions with the UI components we had just created. The second game object, RealmController, will eventually have a script that contains any Realm interactions. For now, we're going to leave these as empty game objects and move on.

Now let's move onto our next scene.

Create a MainScene if you haven't already and start adding UI -> Text to represent the current score and the high score.

Since we probably don't want a solid blue background in our game, we should add a background image. Add an empty game object to the Hierarch and then add a Sprite Renderer component to that object using the inspector. Add whatever image you want to the Sprite field of the Sprite Renderer component.

Since we're going to give the player a few different blasters to choose from, we want to show them which blasters they have at any given time. For this, we should add some simple sprites with blaster images on them.

Create three empty game objects and add a Sprite Renderer component to each of them. For each Sprite field, add the image that you want to use. Then position the sprites to a section on the screen that you're comfortable with.

If you've made it this far, you might have a scene that looks like the following:

Space Shooter Basic MainScene
Space Shooter Basic MainScene

This might be hard to believe, but the visual side of things is almost complete. With just a few more game objects, we can move onto the more exciting logic things.

Like with the LoginScene, the GameController and RealmController game objects will remain empty. There's a small change though. Even though the RealmController will eventually exist in the MainScene, we're not going to create it manually. Instead, just create an empty GameController game object.

This leaves us with the player, enemies, and various blasters.

Starting with the player, create an empty game object and add a Sprite Renderer, Rigidbody 2D, and Box Collider 2D component to the game object. For the Sprite Renderer, add the graphic you want to use for your ship. The Rigidbody 2D and Box Collider 2D have to do with physics and collisions. We're not going to burden ourselves with gravity for this example, so make sure the Body Type for the Rigidbody 2D is Kinematic and the Is Trigger for the Box Collider 2D is enabled. Within the inspector, tag the player game object as "Player."

The blasters and enemies will have the same setup as our player. Create new game objects for each, just like you did the player, only this time select a different graphic for them and give them the tags of "Weapon" or "Enemy" in the inspector.

This is where things get interesting.

We know that there will be more than one enemy in circulation and likewise with your blaster bullets. Rather than creating a bunch of each, take the game objects you used for the blasters and enemies and drag them into your Assets directory. This will convert the game objects into prefabs that can be recycled as many times as you want. Once the prefabs are created, the objects can be removed from the Hierarchy section of your scene. As we progress, we'll be instantiating these prefabs through code.

We're ready to start writing code to give our game life.

#Configuring MongoDB Atlas and MongoDB Realm for Data Synchronization

For this game, we're going to rely on a cloud and synchronization aspect, so there is some additional configuration that we'll need to take care of. However, before we worry about the cloud configurations, let's install the Realm SDK for Unity.

Within Unity, select Window -> Package Manager and then click the little cog icon to find the Advanced Project Settings area.

Install Realm SDK in Unity
Install Realm SDK in Unity

Here you're going to want to add a new registry with the following information:

1name: NPM
2url: https://registry.npmjs.org
3scope(s): io.realm.unity

Even though we're working with Unity, the best way to get the Realm SDK is through NPM, hence the custom registry that we're going to use.

Install Realm SDK in Unity
Install Realm SDK in Unity

With the registry added, we can add an entry for Realm in the project's Packages/manifest.json file. Within the manifest.json file, add the following to the dependencies object:

1"io.realm.unity": "10.3.0"

You can swap the version of Realm with whatever you plan to use.

From a Unity perspective, Realm is ready to be used. Now we just need to configure Realm and Atlas in the cloud.

Within the MongoDB Cloud, assuming you already have a cluster to work with, click the Realm tab and then Create a New App to create a new Realm application.

Create New Realm Application
Create New Realm Application

Name the Realm application whatever you'd like. The MongoDB Atlas cluster requires no special configuration to work with Realm, only that such a cluster exists. Realm will create the necessary databases and collections when the time comes.

Before we start configuring Realm, take note of your Realm App Id in the top left corner of the screen:

Find Realm App Id
Find Realm App Id

The Realm App Id will be very important within the Unity project because it tells the SDK where to sync and authenticate with.

Next you'll want to define what kind of authentication is allowed for your Unity game and the users that are allowed to authenticate. Within the Realm dashboard, click the Authentication tab followed by the Authentication Providers tab. Enable Email / Password if it isn't already enabled. After email and password authentication is enabled for your application, click the Users tab and choose to Add New User with the email and password information of your choice.

The users can be added through an API request, but for this example we're just going to focus on adding them manually.

With the user information added, we need to define the collections and schemas to sync with our game. Click the Schema tab within the Realm dashboard and choose to create a new database and collection if you don't already have a space_shooter database and a PlayerProfile collection.

The schema for the PlayerProfile collection should look like the following:

1{
2 "title": "PlayerProfile",
3 "bsonType": "object",
4 "required": [
5 "high_score",
6 "spark_blaster_enabled",
7 "cross_blaster_enabled",
8 "score",
9 "_partition"
10 ],
11 "properties": {
12 "_id": {
13 "bsonType": "string"
14 },
15 "_partition": {
16 "bsonType": "string"
17 },
18 "high_score": {
19 "bsonType": "int"
20 },
21 "score": {
22 "bsonType": "int"
23 },
24 "spark_blaster_enabled": {
25 "bsonType": "bool"
26 },
27 "cross_blaster_enabled": {
28 "bsonType": "bool"
29 }
30 }
31}

In the above schema, we're saying that we are going to have five fields with the types defined. These fields will eventually be mapped to C# objects within the Unity game. The one field to pay the most attention to is the _partition field. The _partition field will be the most valuable when it comes to sync because it will represent which data is synchronized rather than Realm attempting to synchronize the entire MongoDB Atlas collection.

In our example, the _partition field should hold user email addresses because they are unique and the user will provide them when they log in. With this we can specify that we only want to sync data based on the users email address.

With the schema defined, now we can enable Realm Sync.

Within the Realm dashboard, click on the Sync tab. Specify the cluster and the field to be used as the partition key. You should specify _partition as the partition key in this example, although the actual field name doesn't matter if you wanted to call it something else. Leaving the permissions as the default will give users read and write permissions.

Realm Sync will only sync collections that have a defined schema within Realm. You could have other collections in your MongoDB Atlas cluster, but they won't sync automatically unless you have schemas defined for them.

At this point, Realm is configured in Unity and Realm is configured for synchronization of data. We can now focus on the actual game development.

#Defining the Realm Data Model and Usage Logic

When it comes to data, Realm is going to manage all of it. We need to create a data model that matches the schema that we had just created for synchronization and we need to create the logic for our RealmController game object.

Let's start by creating the model to be used.

Within the Assets folder of your project, create a Scripts folder with a PlayerProfile.cs script in it. The PlayerProfile.cs script should contain the following C# code:

1using Realms;
2using Realms.Sync;
3
4public class PlayerProfile : RealmObject {
5
6 [PrimaryKey]
7 [MapTo("_id")]
8 public string UserId { get; set; }
9
10 [MapTo("high_score")]
11 public int HighScore { get; set; }
12
13 [MapTo("score")]
14 public int Score { get; set; }
15
16 [MapTo("spark_blaster_enabled")]
17 public bool SparkBlasterEnabled { get; set; }
18
19 [MapTo("cross_blaster_enabled")]
20 public bool CrossBlasterEnabled { get; set; }
21
22 public PlayerProfile() {}
23
24 public PlayerProfile(string userId) {
25 this.UserId = userId;
26 this.HighScore = 0;
27 this.Score = 0;
28 this.SparkBlasterEnabled = false;
29 this.CrossBlasterEnabled = false;
30 }
31
32}

What we're doing is we are defining object fields and how they map to a remote document in a MongoDB collection. While our C# object looks like the above, the BSON that we'll see in MongoDB Atlas will look like the following:

1{
2 "_id": "12345",
3 "high_score": 1337,
4 "score": 0,
5 "spark_blaster_enabled": false,
6 "cross_blaster_enabled": false
7}

It's important to note that the documents in Atlas might have more fields than what we see in our game. We'll only be able to use the mapped fields in our game, so if we have for example an email address in our document, we won't see it in the game because it isn't mapped.

With the Realm model in place, we can focus on syncing, querying, and writing our data.

Within the Assets/Scripts directory, add a RealmController.cs script. This script should contain the following C# code:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4using Realms;
5using Realms.Sync;
6using Realms.Sync.Exceptions;
7using System.Threading.Tasks;
8
9public class RealmController : MonoBehaviour {
10
11 public static RealmController Instance;
12
13 public string RealmAppId = "YOUR_REALM_APP_ID_HERE";
14
15 private Realm _realm;
16 private App _realmApp;
17 private User _realmUser;
18
19 void Awake() {
20 DontDestroyOnLoad(gameObject);
21 Instance = this;
22 }
23
24 void OnDisable() {
25 if(_realm != null) {
26 _realm.Dispose();
27 }
28 }
29
30 public async Task<string> Login(string email, string password) {}
31
32 public PlayerProfile GetPlayerProfile() {}
33
34 public void IncreaseScore() {}
35
36 public void ResetScore() {}
37
38 public bool IsSparkBlasterEnabled() {}
39
40 public bool IsCrossBlasterEnabled() {}
41
42}

The above code is incomplete, but it gives you an idea of where we are going.

First, take notice of the RealmAppId variable. You're going to want to use your Realm application so sync can happen based on how you've configured everything. This also applies to the authentication rules that are in place for your particular application.

The RealmController class is going to be used as a singleton object between scenes. The goal is to make sure it cannot be destroyed and everything we do is through a static instance of itself.

In the Awake method, we are saying that the game object that the script is attached to should not be destroyed and that we are setting the static variable to itself. In the OnDisable, we are doing cleanup which should really only happen when the game is closed.

Most of the magic will happen in the Login function:

1public async Task<string> Login(string email, string password) {
2 if(email != "" && password != "") {
3 _realmApp = App.Create(new AppConfiguration(RealmAppId) {
4 MetadataPersistenceMode = MetadataPersistenceMode.NotEncrypted
5 });
6 try {
7 if(_realmUser == null) {
8 _realmUser = await _realmApp.LogInAsync(Credentials.EmailPassword(email, password));
9 _realm = await Realm.GetInstanceAsync(new SyncConfiguration(email, _realmUser));
10 } else {
11 _realm = Realm.GetInstance(new SyncConfiguration(email, _realmUser));
12 }
13 } catch (ClientResetException clientResetEx) {
14 if(_realm != null) {
15 _realm.Dispose();
16 }
17 clientResetEx.InitiateClientReset();
18 }
19 return _realmUser.Id;
20 }
21 return "";
22}

In the above code, we are defining our Realm application based on the application id. Next we are attempting to log into the application using email and password authentication, something we had previously configured in the web dashboard. If successful, we are getting an instance of our Realm to work with going forward. The data to be synchronized is based on our partition field which in this case is the email address. This means we're only synchronizing data for this particular email address.

If all goes smooth with the login, the id for the user is returned.

At some point in time, we're going to need to load the player data. This is where the GetPlayerProfile function comes in:

1public PlayerProfile GetPlayerProfile() {
2 PlayerProfile _playerProfile = _realm.Find<PlayerProfile>(_realmUser.Id);
3 if(_playerProfile == null) {
4 _realm.Write(() => {
5 _playerProfile = _realm.Add(new PlayerProfile(_realmUser.Id));
6 });
7 }
8 return _playerProfile;
9}

What we're doing is we're taking the current Realm instance and we're finding a particular player profile based on the id. If one does not exist, then we create one using the current id. In the end, we're returning a player profile, whether it be one that we had been using or a fresh one.

We know that we're going to be working with score data in our game. We need to be able to increase the score, reset the score, and calculate the high score for a player.

Starting with the IncreaseScore, we have the following:

1public void IncreaseScore() {
2 PlayerProfile _playerProfile = GetPlayerProfile();
3 if(_playerProfile != null) {
4 _realm.Write(() => {
5 _playerProfile.Score++;
6 });
7 }
8}

First we get the player profile and then we take whatever score is associated with it and increase it by one. With Realm we can work with our objects like native C# objects. The exception is that when we want to write, we have to wrap it in a Write block. Reads we don't have to.

Next let's look at the ResetScore function:

1public void ResetScore() {
2 PlayerProfile _playerProfile = GetPlayerProfile();
3 if(_playerProfile != null) {
4 _realm.Write(() => {
5 if(_playerProfile.Score > _playerProfile.HighScore) {
6 _playerProfile.HighScore = _playerProfile.Score;
7 }
8 _playerProfile.Score = 0;
9 });
10 }
11}

In the end we want to zero out the score, but we also want to see if our current score is the highest score before we do. We can do all this within the Write block and it will synchronize to the server.

Finally we have our two functions to tell us if a certain blaster is available to us:

1public bool IsSparkBlasterEnabled() {
2 PlayerProfile _playerProfile = GetPlayerProfile();
3 return _playerProfile != null ? _playerProfile.SparkBlasterEnabled : false;
4}

The reason our blasters are data dependent is because we may want to unlock them based on points or through a micro-transaction. In this case, maybe Realm Sync takes care of it.

The IsCrossBlasterEnabled function isn't much different:

1public bool IsCrossBlasterEnabled() {
2 PlayerProfile _playerProfile = GetPlayerProfile();
3 return _playerProfile != null ? _playerProfile.CrossBlasterEnabled : false;
4}

The difference is we are using a different field from our data model.

With the Realm logic in place for the game, we can focus on giving the other game objects life through scripts.

#Developing the Game-Play Logic Scripts for the Space Shooter Game Objects

Almost every game object that we've created will be receiving a script with logic. To keep the flow appropriate, we're going to add logic in a natural progression. This means we're going to start with the LoginScene and each of the game objects that live in it.

For the LoginScene, only two game objects will be receiving scripts:

  • LoginController
  • RealmController

Since we already have a RealmController.cs script file, go ahead and attach it to the RealmController game object as a component.

Next up, we need to create an Assets/Scripts/LoginController.cs file with the following C# code:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4using UnityEngine.UI;
5using UnityEngine.SceneManagement;
6
7public class LoginController : MonoBehaviour {
8
9 public Button LoginButton;
10 public InputField UsernameInput;
11 public InputField PasswordInput;
12
13 void Start() {
14 UsernameInput.text = "nic.raboy@mongodb.com";
15 PasswordInput.text = "password1234";
16 LoginButton.onClick.AddListener(Login);
17 }
18
19 async void Login() {
20 if(await RealmController.Instance.Login(UsernameInput.text, PasswordInput.text) != "") {
21 SceneManager.LoadScene("MainScene");
22 }
23 }
24
25 void Update() {
26 if(Input.GetKey("escape")) {
27 Application.Quit();
28 }
29 }
30
31}

There's not a whole lot going on since the backbone of this script is in the RealmController.cs file.

What we're doing in the LoginController.cs file is we're defining the UI components which we'll link through the Unity IDE. When the script starts, we're going to default the values of our input fields and we're going to assign a click event listener to the button.

When the button is clicked, the Login function from the RealmController.cs file is called and we pass the provided email and password. If we get an id back, we know we were successful so we can switch to the next scene.

The Update method isn't a complete necessity, but if you want to be able to quit the game with the escape key, that is what this particular piece of logic does.

Attach the LoginController.cs script to the LoginController game object as a component and then drag each of the corresponding UI game objects into the script via the game object inspector. Remember, we defined public variables for each of the UI components. We just need to tell Unity what they are by linking them in the inspector.

The LoginScene logic is complete. Can you believe it? This is because the Realm SDK for Unity is doing all the heavy lifting for us.

The MainScene has a lot more going on, but we'll break down what's happening.

Let's start with something you don't actually see but that controls all of our prefab instances. I'm talking about the object pooling script.

In short, creating and destroying game objects on-demand is resource intensive. Instead, we should create a fixed amount of game objects when the game loads and hide them or show them based on when they are needed. This is what an object pool does.

Create an Assets/Scripts/ObjectPool.cs file with the following C# code:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class ObjectPool : MonoBehaviour
6{
7
8 public static ObjectPool SharedInstance;
9
10 private List<GameObject> pooledEnemies;
11 private List<GameObject> pooledBlasters;
12 private List<GameObject> pooledCrossBlasts;
13 private List<GameObject> pooledSparkBlasts;
14 public GameObject enemyToPool;
15 public GameObject blasterToPool;
16 public GameObject crossBlastToPool;
17 public GameObject sparkBlastToPool;
18 public int amountOfEnemiesToPool;
19 public int amountOfBlastersToPool;
20 public int amountOfCrossBlastsToPool;
21 public int amountOfSparkBlastsToPool;
22
23 void Awake() {
24 SharedInstance = this;
25 }
26
27 void Start() {
28 pooledEnemies = new List<GameObject>();
29 pooledBlasters = new List<GameObject>();
30 pooledCrossBlasts = new List<GameObject>();
31 pooledSparkBlasts = new List<GameObject>();
32 GameObject tmpEnemy;
33 GameObject tmpBlaster;
34 GameObject tmpCrossBlast;
35 GameObject tmpSparkBlast;
36 for(int i = 0; i < amountOfEnemiesToPool; i++) {
37 tmpEnemy = Instantiate(enemyToPool);
38 tmpEnemy.SetActive(false);
39 pooledEnemies.Add(tmpEnemy);
40 }
41 for(int i = 0; i < amountOfBlastersToPool; i++) {
42 tmpBlaster = Instantiate(blasterToPool);
43 tmpBlaster.SetActive(false);
44 pooledBlasters.Add(tmpBlaster);
45 }
46 for(int i = 0; i < amountOfCrossBlastsToPool; i++) {
47 tmpCrossBlast = Instantiate(crossBlastToPool);
48 tmpCrossBlast.SetActive(false);
49 pooledCrossBlasts.Add(tmpCrossBlast);
50 }
51 for(int i = 0; i < amountOfSparkBlastsToPool; i++) {
52 tmpSparkBlast = Instantiate(sparkBlastToPool);
53 tmpSparkBlast.SetActive(false);
54 pooledSparkBlasts.Add(tmpSparkBlast);
55 }
56 }
57
58 public GameObject GetPooledEnemy() {
59 for(int i = 0; i < amountOfEnemiesToPool; i++) {
60 if(pooledEnemies[i].activeInHierarchy == false) {
61 return pooledEnemies[i];
62 }
63 }
64 return null;
65 }
66
67 public GameObject GetPooledBlaster() {
68 for(int i = 0; i < amountOfBlastersToPool; i++) {
69 if(pooledBlasters[i].activeInHierarchy == false) {
70 return pooledBlasters[i];
71 }
72 }
73 return null;
74 }
75
76 public GameObject GetPooledCrossBlast() {
77 for(int i = 0; i < amountOfCrossBlastsToPool; i++) {
78 if(pooledCrossBlasts[i].activeInHierarchy == false) {
79 return pooledCrossBlasts[i];
80 }
81 }
82 return null;
83 }
84
85 public GameObject GetPooledSparkBlast() {
86 for(int i = 0; i < amountOfSparkBlastsToPool; i++) {
87 if(pooledSparkBlasts[i].activeInHierarchy == false) {
88 return pooledSparkBlasts[i];
89 }
90 }
91 return null;
92 }
93
94}

The above object pooling logic is not code optimized because I wanted to keep it readable. If you want to see an optimized version, check out a previous tutorial I wrote on the subject.

So let's break down what we're doing in this object pool.

We have four different game objects to pool:

  • Enemies
  • Spark Blasters
  • Cross Blasters
  • Regular Blasters

These need to be pooled because there could be more than one of the same object at any given time. We're using public variables for each of the game objects and quantities so that we can properly link them to actual game objects in the Unity IDE.

Like with the RealmController.cs script, this script will also act as a singleton to be used as needed.

In the Start method, we are instantiating a game object, as per the quantities defined through the Unity IDE, and adding them to a list. Ideally the linked game object should be one of the prefabs that we previously defined. The list of instantiated game objects represent our pools. We have four object pools to pull from.

Pulling from the pool is as simple as creating a function for each pool and seeing what's available. Take the GetPooledEnemy function for example:

1public GameObject GetPooledEnemy() {
2 for(int i = 0; i < amountOfEnemiesToPool; i++) {
3 if(pooledEnemies[i].activeInHierarchy == false) {
4 return pooledEnemies[i];
5 }
6 }
7 return null;
8}

In the above code, we loop through each object in our pool, in this case enemies. If an object is inactive it means we can pull it and use it. If our pool is depleted, then we either defined too small of a pool or we need to wait until something is available.

I like to pool about 50 of each game object even if I only ever plan to use 10. Doesn't hurt to have excess as it's still less resource-heavy than creating and destroying game objects as needed.

The ObjectPool.cs file should be attached as a component to the GameController game object. After attaching, make sure you assign your prefabs and the pooled quantities using the game object inspector within the Unity IDE.

The ObjectPool.cs script isn't the only script we're going to attach to the GameController game object. We need to create a script that will control the flow of our game. Create an Assets/Scripts/GameController.cs file with the following C# code:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4using UnityEngine.UI;
5
6public class GameController : MonoBehaviour {
7
8 public float timeUntilEnemy = 1.0f;
9 public float minTimeUntilEnemy = 0.25f;
10 public float maxTimeUntilEnemy = 2.0f;
11
12 public GameObject SparkBlasterGraphic;
13 public GameObject CrossBlasterGraphic;
14
15 public Text highScoreText;
16 public Text scoreText;
17
18 private PlayerProfile _playerProfile;
19
20 void OnEnable() {
21 _playerProfile = RealmController.Instance.GetPlayerProfile();
22 highScoreText.text = "HIGH SCORE: " + _playerProfile.HighScore.ToString();
23 scoreText.text = "SCORE: " + _playerProfile.Score.ToString();
24 }
25
26 void Update() {
27 highScoreText.text = "HIGH SCORE: " + _playerProfile.HighScore.ToString();
28 scoreText.text = "SCORE: " + _playerProfile.Score.ToString();
29 timeUntilEnemy -= Time.deltaTime;
30 if(timeUntilEnemy <= 0) {
31 GameObject enemy = ObjectPool.SharedInstance.GetPooledEnemy();
32 if(enemy != null) {
33 enemy.SetActive(true);
34 }
35 timeUntilEnemy = Random.Range(minTimeUntilEnemy, maxTimeUntilEnemy);
36 }
37 if(_playerProfile != null) {
38 SparkBlasterGraphic.SetActive(_playerProfile.SparkBlasterEnabled);
39 CrossBlasterGraphic.SetActive(_playerProfile.CrossBlasterEnabled);
40 }
41 if(Input.GetKey("escape")) {
42 Application.Quit();
43 }
44 }
45
46}

There's a diverse set of things happening in the above script, so let's break them down.

You'll notice the following public variables:

1public float timeUntilEnemy = 1.0f;
2public float minTimeUntilEnemy = 0.25f;
3public float maxTimeUntilEnemy = 2.0f;

We're going to use these variables to define when a new enemy should be activated.

The timeUntilEnemy represents how much actual time from the current time until a new enemy should be pulled from the object pool. The minTimeUntilEnemy and maxTimeUntilEnemy will be used for randomizing what the timeUntilEnemy value should become after an enemy is pooled. It's boring to have all enemies appear after a fixed amount of time, so the minimum and maximum values keep things interesting.

1public GameObject SparkBlasterGraphic;
2public GameObject CrossBlasterGraphic;
3
4public Text highScoreText;
5public Text scoreText;

Remember those UI components and sprites to represent enabled blasters we had created earlier in the Unity IDE? When we attach this script to the GameController game object, you're going to want to assign the other components in the game object inspector.

This brings us to the OnEnable method:

1void OnEnable() {
2 _playerProfile = RealmController.Instance.GetPlayerProfile();
3 highScoreText.text = "HIGH SCORE: " + _playerProfile.HighScore.ToString();
4 scoreText.text = "SCORE: " + _playerProfile.Score.ToString();
5}

The OnEnable method is where we're going to get our current player profile and then update the score values visually based on the data stored in the player profile. The Update method will continuously update those score values for as long as the scene is showing.

1void Update() {
2 highScoreText.text = "HIGH SCORE: " + _playerProfile.HighScore.ToString();
3 scoreText.text = "SCORE: " + _playerProfile.Score.ToString();
4 timeUntilEnemy -= Time.deltaTime;
5 if(timeUntilEnemy <= 0) {
6 GameObject enemy = ObjectPool.SharedInstance.GetPooledEnemy();
7 if(enemy != null) {
8 enemy.SetActive(true);
9 }
10 timeUntilEnemy = Random.Range(minTimeUntilEnemy, maxTimeUntilEnemy);
11 }
12 if(_playerProfile != null) {
13 SparkBlasterGraphic.SetActive(_playerProfile.SparkBlasterEnabled);
14 CrossBlasterGraphic.SetActive(_playerProfile.CrossBlasterEnabled);
15 }
16 if(Input.GetKey("escape")) {
17 Application.Quit();
18 }
19}

In the Update method, every time it's called, we subtract the delta time from our timeUntilEnemy variable. When the value is zero, we attempt to get a new enemy from the object pool and then reset the timer. Outside of the object pooling, we're also checking to see if the other blasters have become enabled. If they have been, we can update the game object status for our sprites. This will allow us to easily show and hide these sprites.

If you haven't already, attach the GameController.cs script to the GameController game object. Remember to update any values for the script within the game object inspector.

If we were to run the game, every enemy would have the same position and they would not be moving. We need to assign logic to the enemies.

Create an Assets/Scripts/Enemy.cs file with the following C# code:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class Enemy : MonoBehaviour {
6
7 public float movementSpeed = 5.0f;
8
9 void OnEnable() {
10 float randomPositionY = Random.Range(-4.0f, 4.0f);
11 transform.position = new Vector3(10.0f, randomPositionY, 0);
12 }
13
14 void Update() {
15 transform.position += Vector3.left * movementSpeed * Time.deltaTime;
16 if(transform.position.x < -10.0f) {
17 gameObject.SetActive(false);
18 }
19 }
20
21 void OnTriggerEnter2D(Collider2D collider) {
22 if(collider.tag == "Weapon") {
23 gameObject.SetActive(false);
24 RealmController.Instance.IncreaseScore();
25 }
26 }
27
28}

When the enemy is pulled from the object pool, the game object becomes enabled. So the OnEnable method picks a random y-axis position for the game object. For every frame, the Update method will move the game object along the x-axis. If the game object goes off the screen, we can safely add it back into the object pool.

The OnTriggerEnter2D method is for our collision detection. We're not doing physics collisions so this method just tells us if the objects have touched. If the current game object, in this case the enemy, has collided with a game object tagged as a weapon, then add the enemy back into the queue and increase the score.

Attach the Enemy.cs script to your enemy prefab.

By now, your game probably looks something like this, minus the animations:

Space Shooter Enemies
Space Shooter Enemies

We won't be worrying about animations in this tutorial. Consider that part of your extracurricular challenge after completing this tutorial.

So we have a functioning enemy pool. Let's look at the blaster logic since it is similar.

Create an Assets/Scripts/Blaster.cs file with the following C# logic:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class Blaster : MonoBehaviour {
6
7 public float movementSpeed = 5.0f;
8 public float decayRate = 2.0f;
9
10 private float timeToDecay;
11
12 void OnEnable() {
13 timeToDecay = decayRate;
14 }
15
16 void Update() {
17 timeToDecay -= Time.deltaTime;
18 transform.position += Vector3.right * movementSpeed * Time.deltaTime;
19 if(transform.position.x > 10.0f || timeToDecay <= 0) {
20 gameObject.SetActive(false);
21 }
22 }
23
24 void OnTriggerEnter2D(Collider2D collider) {
25 if(collider.tag == "Enemy") {
26 gameObject.SetActive(false);
27 }
28 }
29
30}

Look mildly familiar to the enemy? It is similar.

We need to first define how fast each blaster should move and how quickly the blaster should disappear if it hasn't hit anything.

In the Update method will subtract the current time from our blaster decay time. The blaster will continue to move along the x-axis until it has either gone off screen or it has decayed. In this scenario, the blaster is added back into the object pool. If the blaster collides with a game object tagged as an enemy, the blaster is also added back into the pool. Remember, the blaster will likely be tagged as a weapon so the Enemy.cs script will take care of adding the enemy back into the object pool.

Attach the Blaster.cs script to your blaster prefab and apply any value settings as necessary with the Unity IDE in the inspector.

To make the game interesting, we're going to add some very slight differences to the other blasters.

Create an Assets/Scripts/CrossBlast.cs script with the following C# code:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class CrossBlast : MonoBehaviour {
6
7 public float movementSpeed = 5.0f;
8
9 void Update() {
10 transform.position += Vector3.right * movementSpeed * Time.deltaTime;
11 if(transform.position.x > 10.0f) {
12 gameObject.SetActive(false);
13 }
14 }
15
16 void OnTriggerEnter2D(Collider2D collider) { }
17
18}

At a high level, this blaster behaves the same. However, if it collides with an enemy, it keeps going. It only goes back into the object pool when it goes off the screen. So there is no decay and it isn't a one enemy per blast weapon.

Let's look at an Assets/Scripts/SparkBlast.cs script:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class SparkBlast : MonoBehaviour {
6
7 public float movementSpeed = 5.0f;
8
9 void Update() {
10 transform.position += Vector3.right * movementSpeed * Time.deltaTime;
11 if(transform.position.x > 10.0f) {
12 gameObject.SetActive(false);
13 }
14 }
15
16 void OnTriggerEnter2D(Collider2D collider) {
17 if(collider.tag == "Enemy") {
18 gameObject.SetActive(false);
19 }
20 }
21
22}

The minor difference in the above script is that it has no decay, but it can only ever destroy one enemy.

Make sure you attach these scripts to the appropriate blaster prefabs.

We're almost done! We have one more script and that's for the actual player!

Create an Assets/Scripts/Player.cs file and add the following code:

1using System.Collections;
2using System.Collections.Generic;
3using UnityEngine;
4
5public class Player : MonoBehaviour
6{
7
8 public float movementSpeed = 5.0f;
9 public float respawnSpeed = 8.0f;
10 public float weaponFireRate = 0.5f;
11
12 private float nextBlasterTime = 0.0f;
13 private bool isRespawn = true;
14
15 void Update() {
16 if(isRespawn == true) {
17 transform.position = Vector2.MoveTowards(transform.position, new Vector2(-6.0f, -0.25f), respawnSpeed * Time.deltaTime);
18 if(transform.position == new Vector3(-6.0f, -0.25f, 0.0f)) {
19 isRespawn = false;
20 }
21 } else {
22 if(Input.GetKey(KeyCode.UpArrow) && transform.position.y < 4.0f) {
23 transform.position += Vector3.up * movementSpeed * Time.deltaTime;
24 } else if(Input.GetKey(KeyCode.DownArrow) && transform.position.y > -4.0f) {
25 transform.position += Vector3.down * movementSpeed * Time.deltaTime;
26 }
27 if(Input.GetKey(KeyCode.Space) && Time.time > nextBlasterTime) {
28 nextBlasterTime = Time.time + weaponFireRate;
29 GameObject blaster = ObjectPool.SharedInstance.GetPooledBlaster();
30 if(blaster != null) {
31 blaster.SetActive(true);
32 blaster.transform.position = new Vector3(transform.position.x + 1, transform.position.y);
33 }
34 }
35 if(RealmController.Instance.IsCrossBlasterEnabled()) {
36 if(Input.GetKey(KeyCode.B) && Time.time > nextBlasterTime) {
37 nextBlasterTime = Time.time + weaponFireRate;
38 GameObject crossBlast = ObjectPool.SharedInstance.GetPooledCrossBlast();
39 if(crossBlast != null) {
40 crossBlast.SetActive(true);
41 crossBlast.transform.position = new Vector3(transform.position.x + 1, transform.position.y);
42 }
43 }
44 }
45 if(RealmController.Instance.IsSparkBlasterEnabled()) {
46 if(Input.GetKey(KeyCode.V) && Time.time > nextBlasterTime) {
47 nextBlasterTime = Time.time + weaponFireRate;
48 GameObject sparkBlast = ObjectPool.SharedInstance.GetPooledSparkBlast();
49 if(sparkBlast != null) {
50 sparkBlast.SetActive(true);
51 sparkBlast.transform.position = new Vector3(transform.position.x + 1, transform.position.y);
52 }
53 }
54 }
55 }
56 }
57
58 void OnTriggerEnter2D(Collider2D collider) {
59 if(collider.tag == "Enemy" && isRespawn == false) {
60 RealmController.Instance.ResetScore();
61 transform.position = new Vector3(-10.0f, -0.25f, 0.0f);
62 isRespawn = true;
63 }
64 }
65
66}

Looking at the above script, we have a few variables to keep track of:

1public float movementSpeed = 5.0f;
2public float respawnSpeed = 8.0f;
3public float weaponFireRate = 0.5f;
4
5private float nextBlasterTime = 0.0f;
6private bool isRespawn = true;

We want to define how fast the player can move, how long it takes for the respawn animation to happen, and how fast you're allowed to fire blasters.

In the Update method, we first check to see if we are currently respawning:

1transform.position = Vector2.MoveTowards(transform.position, new Vector2(-6.0f, -0.25f), respawnSpeed * Time.deltaTime);
2if(transform.position == new Vector3(-6.0f, -0.25f, 0.0f)) {
3 isRespawn = false;
4}

If we are respawning, then we need to smoothly move the player game object towards a particular coordinate position. When the game object has reached that new position, then we can disable the respawn indicator that prevents us from controlling the player.

If we're not respawning, we can check to see if the movement keys were pressed:

1if(Input.GetKey(KeyCode.UpArrow) && transform.position.y < 4.0f) {
2 transform.position += Vector3.up * movementSpeed * Time.deltaTime;
3} else if(Input.GetKey(KeyCode.DownArrow) && transform.position.y > -4.0f) {
4 transform.position += Vector3.down * movementSpeed * Time.deltaTime;
5}

When pressing a key, as long as we haven't moved outside our y-axis boundary, we can adjust the position of the player. Since this is in the Update method, the movement should be smooth for as long as you are holding a key.

Using a blaster isn't too different:

1if(Input.GetKey(KeyCode.Space) && Time.time > nextBlasterTime) {
2 nextBlasterTime = Time.time + weaponFireRate;
3 GameObject blaster = ObjectPool.SharedInstance.GetPooledBlaster();
4 if(blaster != null) {
5 blaster.SetActive(true);
6 blaster.transform.position = new Vector3(transform.position.x + 1, transform.position.y);
7 }
8}

If the particular blaster key is pressed and our rate limit isn't exceeded, we can update our nextBlasterTime based on the rate limit, pull a blaster from the object pool, and let the blaster do its magic based on the Blaster.cs script. All we're doing in the Player.cs script is checking to see if we're allowed to fire and if we are pull from the pool.

The data dependent spark and cross blasters follow the same rules, the exception being that we first check to see if they are enabled in our player profile.

Finally, we have our collisions:

1void OnTriggerEnter2D(Collider2D collider) {
2 if(collider.tag == "Enemy" && isRespawn == false) {
3 RealmController.Instance.ResetScore();
4 transform.position = new Vector3(-10.0f, -0.25f, 0.0f);
5 isRespawn = true;
6 }
7}

If our player collides with a game object tagged as an enemy and we're not currently respawning, then we can reset the score and trigger the respawn.

Make sure you attach this Player.cs script to your Player game object.

If everything worked out, the game should be functional at this point. If something isn't working correctly, double check the following:

  • Make sure each of your game objects is properly tagged.
  • Make sure the scripts are attached to the proper game object or prefab.
  • Make sure the values on the scripts have been defined through the Unity IDE inspector.

Play around with the game and setting values within MongoDB Atlas.

#Conclusion

You just saw how to create a space shooter type game with Unity that syncs with MongoDB Atlas by using the Realm SDK for Unity and Realm Sync. Realm only played a small part in this game because that is the beauty of Realm. You can get data persistence and sync with only a few lines of code.

Want to give this project a try? I've uploaded all of the source code to GitHub. You just need to clone the project, replace my Realm ID with yours, and build the project. Of course you'll still need to have properly configured Atlas and Realm in the cloud.

If you're looking for a slightly slower introduction to Realm with Unity, check out a previous tutorial that I wrote on the subject.

If you'd like to connect with us further, don't forget to visit the community forums.

Rate this article

Related

Getting Started with the Realm SDK for Unity
Build an Infinite Runner Game with Unity and the Realm Unity SDK
MongoDB logo
© 2021 MongoDB, Inc.

About

  • Careers
  • Legal Notices
  • Privacy Notices
  • Security Information
  • Trust Center
© 2021 MongoDB, Inc.