Building a Space Shooter Game in Unity that Syncs with Realm and MongoDB Atlas
Rate this tutorial
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.
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:
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.
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 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 an Atlas App Services application. Both of these can be configured for free . Don't worry about the finer details of the configuration because we'll get to those as we progress in the tutorial.
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:
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:
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:
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.
Within Unity, select Window -> Package Manager and then click the little cog icon to find the Advanced Project Settings area.
Here you're going to want to add a new registry with the following information:
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.
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
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 Device Sync and Atlas in the cloud.
Name the application whatever you'd like. The MongoDB Atlas cluster requires no special configuration to work with App Services, only that such a cluster exists. App Services will create the necessary databases and collections when the time comes.
Before we start configuring your app, take note of your App ID in the top left corner of the screen:
The 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 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 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:
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
_partitionfield will be the most valuable when it comes to sync because it will represent which data is synchronized rather than attempting to synchronize the entire MongoDB Atlas collection.
In our example, the
_partitionfield 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 Atlas Device Sync.
Within the dashboard, click on the Sync tab. Specify the cluster and the field to be used as the partition key. You should specify
_partitionas 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.
Atlas Device Sync will only sync collections that have a defined schema. 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, we can now focus on the actual game development.
When it comes to data, your Atlas App Services app 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:
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:
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 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:
The above code is incomplete, but it gives you an idea of where we are going.
First, take notice of the
AppIdvariable. You're going to want to use your App Services 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.
RealmControllerclass 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.
Awakemethod, 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
In the above code, we are defining our 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
GetPlayerProfilefunction comes in:
What we're doing is we're taking the current 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:
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
Writeblock. Reads we don't have to.
Next let's look at the
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
Writeblock and it will synchronize to the server.
Finally we have our two functions to tell us if a certain blaster is available to us:
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.
IsCrossBlasterEnabledfunction isn't much different:
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.
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:
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:
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
Loginfunction 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.
Updatemethod 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 .NET 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:
So let's break down what we're doing in this object pool.
We have four different game objects to pool:
- 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.
Startmethod, 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
GetPooledEnemyfunction for example:
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:
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:
We're going to use these variables to define when a new enemy should be activated.
timeUntilEnemyrepresents how much actual time from the current time until a new enemy should be pulled from the object pool. The
maxTimeUntilEnemywill be used for randomizing what the
timeUntilEnemyvalue 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.
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
OnEnablemethod 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
Updatemethod will continuously update those score values for as long as the scene is showing.
Updatemethod, every time it's called, we subtract the delta time from our
timeUntilEnemyvariable. 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:
When the enemy is pulled from the object pool, the game object becomes enabled. So the
OnEnablemethod picks a random y-axis position for the game object. For every frame, the
Updatemethod 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.
OnTriggerEnter2Dmethod 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:
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:
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.
Updatemethod 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:
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:
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:
Looking at the above script, we have a few variables to keep track of:
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.
Updatemethod, we first check to see if we are currently respawning:
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:
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
Updatemethod, the movement should be smooth for as long as you are holding a key.
Using a blaster isn't too different:
If the particular blaster key is pressed and our rate limit isn't exceeded, we can update our
nextBlasterTimebased 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:
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.
You just saw how to create a space shooter type game with Unity that syncs with MongoDB Atlas by using the Realm .NET SDK for Unity and Atlas Device 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.