Now that we have a basic gameplay experience, it's time to make the game end sometime, both in the cases of winning and losing. One common way to implement this is through separated components with the responsibility of overseeing a set of Objects to detect certain situations that need to happen, such as the Player life becoming 0 or all of the waves being cleared. We will implement this through the concept of Managers, components that will manage several Objects, monitoring them.
In this chapter, we will examine the following Manager concepts:
With this knowledge, you will be able to not only create the victory and loose condition of the game, but also do that in a properly structured way using design patterns such as Singleton and Event Listeners. These skills are not only useful for creating the code for the winning and losing functions of the game, but any code in general.
Not every Object in the scene should be something that can be seen, heard, or collided with. Some Objects can also exist with a conceptual meaning, not something tangible. Imagine you need to keep a count of the number of enemies, where do you save that? You also need someplace to save the current score of the Player, and you may be thinking it could be on the Player itself, but what happens if the Player dies and respawns? The data would be lost! In such scenarios, the concept of a Manager can be a useful way of solving this in our first games, so let's explore it.
In this chapter, we are going to see the following Object Manager concepts:
We will start by discussing what the Singleton design pattern is and how it helps us simplify the communication of Objects. With it we will create Managers Objects, which will allow us to centralize information of a group of Objects, among other things. Let's start discussing the Singleton design pattern.
Design patterns are usually described as common solutions to common problems. There are several coding design decisions you will have to make while you code your game, but luckily, the way to tackle the most common situations are well known and documented. In this section, we are going to discuss one of the most common design patterns, the Singleton, a very controversial but convenient one to implement in simple projects.
A Singleton pattern is used when we need a single instance of an Object, meaning that there shouldn't be more than one instance of a class and that we want it to be easily accessible (not necessarily, but useful in our scenario). We have plenty of cases in our game where this can be applied, for example, ScoreManager, a component that will hold the current score. In this case, we will never have more than one score, so we can take advantage of the benefits of the Singleton Manager here.
One benefit is being sure that we won't have duplicated scores, which makes our code less error-prone. Also, so far, we have needed to create public references and drag Objects via the Editor to connect two Objects or look for them using GetComponent, but with this pattern, we will have global access to our Singleton component, meaning you can just write the name of the component and you will access it. In the end, there's just one ScoreManager component, so specifying which one via the Editor is redundant. This is similar to Time.deltaTime, the class responsible for managing time—we have just one time.
Important note
If you are an advanced programmer, you may be thinking about code testing and dependency injection now, and you are right, but remember, we are trying to write simple code so far, so we will stick to this simple solution.
Let's create a Score Manager Object, responsible for handling the score, to show an example of a Singleton by doing the following:
The idea is to save the reference to the only ScoreManager instance in the instance static field, but if by mistake the user creates two objects with the ScoreManager component, this if statement will detect it and inform the user of the error, asking them to take action. In this scenario, the first ScoreManager instance to execute Awake will find that there's no instance set (the field is null) so it will set itself as the current instance, while the second ScoreManager instance will find the instance is already set and will print the message. Remember that instance is a static field, the one shared between all classes, unlike regular reference fields, where each component will have its own reference, so in this case, we have two ScoreManagers added to the scene, and both will share the same instance field.
To improve the example a little bit, it would be ideal to have a simple way to find the second ScoreManager in the game. It will be hidden somewhere in the Hierarchy and it would be difficult to find. We can replace print with Debug.Log, which is basically the same but allows us to pass a second argument to the function, which is an Object, to highlight when the message is clicked in the console. In this case, we will pass the gameObject reference to allow the console to highlight the duplicated Object:
The next step would be to use this Singleton somewhere, so in this case, we will make the enemies give points when they are killed by doing the following:
Important note
Consider that the OnDestroy function is also called when we change scenes or the game is quitting, so in this scenario, maybe we will get points when changing scenes, which is not correct. So far, this is not a problem in our case, but later in this chapter, we will see a way to prevent this.
As you can see, the Singleton simplified a lot the way to access ScoreManager and prevented us from having two versions of the same Object, which will help us to reduce errors in our code. Something to take into account is that now you will be tempted to just make everything a Singleton, such as the Player life or Player bullets and use it just to make your life easier to create gameplay such as power-ups, and while that will totally work, remember that your game will change, and I mean, change a lot; any real project will suffer that. Maybe today, the game will have just one Player, but maybe in the future, you will want to add a second Player or an AI companion, and you want the power-ups to affect them too, so if you abuse the Singleton pattern, you will have trouble handling those scenarios. Maybe the companion will try to get the pickup but the main Player will be healed instead!
The point here is to try to use the pattern as few times as you can, in cases where you don't have any other way to solve the problem. To be honest, there are always ways to solve problems without Singleton, but they are a little bit more difficult to implement for beginners, so I prefer to simplify your life a little bit to keep you motivated. With enough practice, you will reach a point where you will be ready to improve your coding standards.
Now that we know how to create Singletons, let's finish some other Managers that we will need later in the game.
Sometimes, we need a place to put together information about a group of similar Objects, for example, an Enemy Manager, to check the number of enemies and potentially access an array of them to iterate over them and do something, or maybe MissionManager, to have access to all of the active missions in our game. Again, these cases can be considered Singletons, single Objects that won't be repeated (in our current game design), so let's create the ones we will need in our game, that is, EnemyManager and WaveManager.
In our game, EnemyManager and WaveManager will just be places to save an array of references to the existent enemies and waves in our game, just as a way to know the current amount of them. There are ways to search all Objects of a certain type to calculate the count of them, but those functions are expensive and not recommended to use unless you really know what you are doing. So, having a Singleton with a separate updated list of references to the target Object type will require more code but will perform better. Also, as the game features increase, these Managers will have more functionality and helper functions to interact with those Objects.
Let's start with the enemies Manager by doing the following:
A list in C# represents a dynamic array, an array capable of adding and removing Objects. You will see that you can add and remove elements to this list in the Editor, but keep the list empty; we will add enemies another way. Take into account that List is in the System.Collections.Generic namespace; you will find the using sentence at the beginning of our script. Also, consider that you can make the list private and expose it to the code via a getter instead of making it a public field; but as usual, we will make our code as simple as possible for now.
Important note
Remember that List is a class type, so it must be instantiated, but as this type has exposing support in the Editor, Unity will automatically instantiate it. You must use the new keyword to instantiate it in cases where you want a non-Editor-exposed list, such as a private one or a list in a regular non-component C# class.
The C# list internally is implemented as an array. if you need a linked list, look at the LinkedList collection type.
Important Note
The problem we solved with the Start function is called a race condition, that is, when two pieces of code are not guaranteed to be executed in the same order, whereas Awake execution order can change due to different reasons. There are plenty of situations in code where this will happen, so pay attention to the possible race conditions in your code. Also, you might consider using more advanced solutions such as lazy initialization here, which can give you better stability, but again, for the sake of simplicity and exploring the Unity API, we will use the Start function approach for now.
With this, now we have a centralized place to access all of the active enemies in a simple but efficient way. I challenge you to do the same with the waves, using WaveManager, which will have the collection of all active Waves to later check whether all waves finished their work to consider the game as won. Take some time to solve this; you will find the solution in the following screenshots, starting with WavesManager:
You will need also the WavesSpawner script:
As you can see, WaveManager is created the same way EnemyManager was, just a Singleton with a list of WaveSpawner references, but WaveSpawner is different. We execute the Add function of the list in the Start event of WaveSpawner to register the wave as an active one, but the Remove function needs more work.
The idea is to deregister the wave from the active waves list when it finishes spawning all enemies when the spawner finishes its work. Before this modification, we used Invoke to call the CancelIncoke function after a while to stop the spawning, but now we need to do more after the end time. Instead of calling CancelInvoke after the specified wave end time, we will call a custom function called EndSpawner, which will call CancelInvoke to stop the spawner, Invoke Repeating, but also will call Remove from WavesManager list function to make sure the removing from the list is called exactly when WaveSpawner finishes its work.
Using Object Managers, we have now centralized information about a group of Objects, and we can add all sorts of Objects group logic here, but besides having this information for updating the UI (which we will do in the next chapter), we can use this information to detect whether the Victory and Lose conditions of our game are met, creating a Game Mode Object to detect that.
We have created Objects to simulate lots of gameplay aspects of our game, but the game needs to end sometime, whether we win or lose. As always, the question is where to put this logic and that leads us to further questions. The main questions would be, will we always win or lose the game the same way? Will we have a special level with different criteria than to kill all of the waves, such as a timed survival? Only you know the answer to those questions, but if right now the answer is no, it doesn't mean that it won't change later, so it is advisable to prepare our code to adapt seamlessly to changes.
Important note
To be honest, preparing our code to adapt seamlessly to changes is almost impossible; there's no way to have perfect code that will consider every possible case, and we will always need to rewrite some code sooner or later. We will try to make the code as adaptable as possible to changes; always doing that doesn't consume lots of developing time and it's sometimes preferable to write simple code fast then complex code slow that might not be necessary, and so balance your time budget wisely.
To do this, we will separate the Victory and Lose conditions logic in its own Object, which I like to call the "Game Mode" (not necessarily an industry standard). This will be a component that will oversee the game, checking conditions that need to be met in order to consider the game over. It will be like the referee of our game. The Game Mode will constantly check the information in the Object Managers and maybe other sources of information to detect the needed conditions. Having this Object separated from other Objects allows us to create different levels with different Game Modes; just use another Game Mode script in that level and that's all.
In our case, we will have a single Game Mode for now, which will check whether the number of waves and enemies becomes 0, meaning that we have killed all of the possible enemies and the game is won. Also, it will check whether the life of the Player reaches 0, considering the game as lost in that situation. Let's create it by doing the following:
Important note
Remember that we don't want two instances of this Object, so we can make it a Singleton also, but as this Object won't be accessed by others, that might be redundant; I will leave this up to you. Anyway, remember that this won't prevent you from having two different GameModes instantiated; for doing so, you can create a GameMode base class, with the Singleton functionality ready to prevent two GameModes in the same scene.
Now, it is time to replace the messages with something more interesting. For now, we will just change the current scene to a Win scene and Lose scene, which will only have a UI with a win and lose message and a button to play again. In the future, you can add a Main Menu scene and have an option to get back to it. Let's do that by doing the following:
The idea is that Unity needs you to explicitly declare all scenes that must be included in the game. You might have test scenes or scenes that you don't want to release yet, so that's why we need to do this. In our case, our game will have WinScreen, LoseScreen, and the scene we have created so far with the game scenario, which I called Game, so just drag those scenes from the Project View to the list of the Build Settings window; we will need this to make the Game Mode script change the scenes properly. Also, consider that the first scene in this list will be the first scene to be opened when we play the game in its final version (known as the build), so you may want to rearrange the list according to that:
If you want to chain different levels, you can create a public string field to allow you to specify via the Editor which scenes to load. Remember to have the scenes added to the Build Settings, if not, you will receive an error message in the console when you try to change the scenes:
Important note
Right now, we picked the simplest way to show whether we lost or won, but maybe in the future, you will want something more gentle than a sudden change of the scene, such as maybe waiting a few moments with Invoke to delay that change or directly show the winning message inside the game without changing the scenes. Consider that when testing the game with people and checking whether they understood what happens while they play, game feedback is important to keep the Player aware of what is happening and is not an easy task to tackle.
Now we have a fully functional simple game, with mechanics and win and lose conditions, and while this is enough to start developing other aspects of our game, I want to discuss some issues with our current Manager approach and how to solve them with events.
So far, we used Unity event functions to detect situations that can happen in the game such as Awake and Update. These functions are ways for Unity to communicate two components, as in the case of OnTriggerEnter, which is a way for the Rigidbody to inform other components in the GameObject that a collision has happened. In our case, we are using ifs inside Updates to detect changes on other components, such as GameMode checking whether the number of enemies reached 0. But we can improve this if we are informed by the Enemy Manager when something has changed, and just do the check-in that moment, such as with the Rigidbody telling us the collisions instead of checking collisions every frame.
Also, sometimes, we rely on Unity events to execute logic, such as the score being given in the OnDestroy event, which informs us when the Object is destroyed, but due to the nature of the event, it can be called in situations we don't want to add to the score, such as when the scene is changed or the game is closed. Objects are destroyed in those cases, but not because the Player killed the Enemy, leading to the score being raised when it shouldn't. In this case, it would be great to have an event that tells us that the Player's lives have reached 0 to execute this logic, instead of relying on the general-purpose destroy event.
The idea of events is to improve the model of communication between our Objects, being sure that in the exact moment something happens, the interested parts in that situation are notified to react accordingly. Unity has lots of events, but we can create specific ones to our gameplay logic. Let's start seeing this applied in the Score scenario we discussed earlier; the idea is to make the Life component to have an event to communicate other components that the Object was destroyed because its life reached 0.
There are several ways to implement this, and we will use a little bit of a different approach than the Awake and Update methods; we will use the UnityEvent field type. This is a field type capable of holding references to functions to be executed when we want to, like C# delegates, but with other benefits, such as better Unity Editor integration. To implement this, do the following:
Important note
You can use the generic delegate action or a custom delegate to create events instead of using UnityEvent, and aside from certain performance aspects, the only noticeable difference is that UnityEvent will show up in the Editor, as demonstrated in step 2.
Important note
Consider calling RemoveListener in OnDestroy; as usual, it is convenient to unsubscribe listeners when possible to prevent any memory leak (a reference preventing the GC from deallocating memory). In this scenario, it is not entirely necessary because both the Life and ScoreOnDeath components will be destroyed at the same time, but try to get used to that good practice.
Now that Life has an onDeath event, we can also replace the Player's Life check from the WavesGameMode to use the event by doing the following:
As you can see, creating custom events allows you to detect more specific situations other than the defaults in Unity, and keeps your code clean, without needing to constantly ask conditions in the Update function, which is not necessarily bad, but the event approach generates clearer code.
Remember that we can lose our game also by the Player's Base Life reaching 0, and we will explore the concept of the Player's base later in this book, but for now, let's create a cube that represents the Object that Enemies will attack to reduce the Base Life, like the Base Core. Taking this into account, I challenge you to add this other lose condition to our script. When you finish, you can check the solution in the following screenshot:
As you can see, we just repeated the life event subscription: remember to create an Object to represent the Player's Base damage point, add a Life script to it, and drag that one as the Player Base Life reference of WavesGameMode.
Now, let's keep illustrating this concept by applying it in the Managers to prevent the Game Mode from checking conditions every frame:
Important note
Here, we have the problem that nothing stops us from bypassing those two functions and using the list directly. You can solve that by making the list private and exposing it using the IReadOnlyList interface. Remember that this way, the list won't be visible in the Editor for debugging purposes.
Also, WavesSpawner needs changes:
Yes, this way, we have to write more code than before, and in terms of functionality, we didn't obtain anything new, but in bigger projects, managing conditions through Update checks will lead to different kinds of problems as previously discussed, such as race conditions and performances issues. Having a scalable code base sometimes requires more code, and this is one of those cases.
Before we finish, something to consider is that Unity events are not the only way to create this kind of event communication in Unity; you will find a similar approach called Action, the native C# version of Unity events, which I recommend you to look for if you want to see all of the options out there.
In this chapter, we finished an important part of the game, the ending, either by victory or by defeat. We discussed a simple but powerful way to separate the different layers of responsibilities by using Managers created through Singletons, to guarantee that there's not more than one instance of every kind of manager and simplifying the connections between them through static access (something to consider the day you discover code testing). Also, we visited the concept of events to streamline the communication between Objects to prevent problems and create more meaningful communication between Objects.
With this knowledge, you are now able not only to detect the victory and lose conditions of the game but to also do that in a better-structured way. These patterns can be useful to improve our game code in general, and I recommend you to try to apply it in other relevant scenarios.
In the next chapter, we are going to explore how to create visual and audio feedback to respond to our gameplay, combining scripting and the assets we integrated in Part 2 of this book.