CHAPTER 8
We’re just about done with the game. We have only a little more code to add in order to make it playable.
We currently have no logic to handle the bullet that destroys a ghost. A simple class will take care of that. Add a new class called PlayerBullet to the scripts file and enter the following code.
Code Listing 24: PlayerBullet Class
public class PlayerBullet : MonoBehaviour { void OnTriggerEnter2D(Collider2D collider) { if (Globals.CurGameState == GameState.PlayingGame) { if (collider.gameObject.tag == "Ghost") { Globals.Score += (Globals.DifficultyLevel + 1) * 5; Destroy(collider.gameObject); Destroy(gameObject); } } } void Update () { if (Globals.CurGameState == GameState.PlayingGame) { transform.Translate(Vector2.up * 0.1f); //destroy if at arena wall if (transform.position.x >= 5 || transform.position.x <= -5 || transform.position.y >= 3.4 || transform.position.y <= -3.4) { Destroy(gameObject); //Debug.Log("destroying bullet"); } } } } |
Select the Bullet prefab, click the Scripts folder, and drag the new script into the Inspector.
The OnTriggerEnter2D method is built into Unity for GameObjects that have a 2-D collider attached to them. We simply add the logic we need. If the game hasn’t been paused and the bullet has hit a ghost, we add to the global score variable and destroy both the ghost and bullet instances.
In the Update method, if the game hasn’t been paused, we move the bullet appropriately for the elapsed time since the last frame. If it has hit a wall of the arena, we destroy the instance of the prefab.
Next, the ghost needs some code to handle moving it toward the player. Add a new script called Ghost and add the following code.
Code Listing 25: Ghost Class
public class Ghost : MonoBehaviour { public GameObject Player; void Start () { }
void Update () { if (Globals.CurGameState == GameState.PlayingGame) { float step = Time.deltaTime; transform.position = Vector3.MoveTowards(transform.position, Player.transform.position, step); Vector3 player = Camera.main.WorldToScreenPoint(Player.transform.position); Vector3 screenPoint = Camera.main.WorldToScreenPoint(transform.localPosition); Vector2 offset = new Vector2(player.x - screenPoint.x, player.y - screenPoint.y); float angle = Mathf.Atan2(offset.y, offset.x) * Mathf.Rad2Deg - 90.0f; transform.rotation = Quaternion.Euler(0, 0, angle); } else if (Globals.CurGameState == GameState.GameOver) Destroy(gameObject); } } |
If the player hasn’t paused the game, we move the ghost toward the player based on the elapsed time since the last frame, rotating the sprite if necessary. If the game is over, we destroy the ghost.
We will need a few more global variables.
Code Listing 26: Remaining Global Variables
public static float[] SpawnTimes = new float[] { 2, 1, 0.5f }; public static int Score; |
Lastly, the Game class needs a bit more code.
Code Listing 27: Game Timer Member
private float _timer; |
The timer is used to determine if a ghost is to be spawned. We’ll initialize it in the ResetGame method along with a couple of other variables, as shown in Code Listing 28.
Code Listing 28: ResetGame Method Code
Globals.Health = 100; Globals.Score = 0; Globals.CurGameState = GameState.PlayingGame; _timer = 0.0f; |
Code Listing 29 needs to be added to the beginning of the Update method, replacing the commented-out code to play the sound when the character dies.
Code Listing 29: Game Update Code
if (Globals.CurGameState == GameState.PlayingGame) { _timer += Time.deltaTime; if (_timer >= Globals.SpawnTimes[Globals.DifficultyLevel]) { SpawnGhost(); _timer -= Globals.SpawnTimes[Globals.DifficultyLevel]; } ScoreText.text = "Score: " + Globals.Score.ToString(); HealthText.text = "Health: " + Globals.Health.ToString(); if (Globals.Health == 0) { PlayerDeathAudioSource.Play(); Globals.CurGameState = GameState.GameOver; //kill player, game over GameOverPanel.SetActive(true); } } |
If the player hasn’t paused the game, we increment the timer variable with the elapsed time since the last frame. If that time is equal to or greater than the spawn time for the selected difficulty, we call the SpawnGhost method and decrement the timer by the time amount of that spawn.
We set the score and health UI objects to their current amounts. Because the score and health are changed in other scripts, we must set global variables that we access from the game script.
If the character has been killed, we play his death sound, change the current game state so that other code will know not to run (ghosts spawning and moving, for example), and show the panel notifying the player that the game is over.
While we have a playable game at this point, some things could be added to make it better. We’ll cover a few of them here. I’m sure everyone can come up with their own unique features—that’s part of what makes indie game development so great. There are as many ideas as there are game developers.
In any game that keeps scores, leaderboards are a way for players to measure their progress against themselves and other gamers. Many games with leaderboards will have multiple ways of tracking scores and other information. Even a game as small as ours has several different types of information we can track:
You will notice that some of these types of information will require additional code because the information isn’t currently tracked. You’ll probably want to make that information global. Doing so will enable you to keep the code to save and retrieve the information separate from the main game code. Separating functionality this way is almost always a good idea.
Despite the different data types, we’ll use one class to handle all of them.
Code Listing 30: LeaderboardData Class
public enum LeaderboardType { GhostsDestroyed, PlayerSurvivalTime, Accuracy, GhostSurvivalTime } public enum LeaderboardAmountType { FloatAmount, IntAmount } public class LeaderboardData { public string PlayerName; public LeaderboardType Type; public LeaderboardAmountType AmountType; public float FloatAmount; public int IntAmount; } |
Because we have a distinct value for each leaderboard, we can use a Dictionary to hold the data for all of the leaderboards.
Code Listing 31: Leaderboards Dictionary
public static Dictionary<LeaderboardType, List<LeaderboardData>> Leaderboards; |
In order to track the information for these leaderboards, you’ll need to implement some kind of system to recognize when the events for the leaderboards occur. You could hard code everything by keeping global variables and updating them from whatever code is applicable, which is quick and easy, but it’s not very flexible.
Another method would be to implement events in the classes in which the data for leaderboards gets updated while keeping track of the data in a more central part of your code, such as a LeaderboardsManager class. This takes a little more time, but adding leaderboards will be much quicker to implement and all of your code will be in one central location, which means updating it is much easier.
For the Ghosts Destroyed leaderboard, for example, all you need is a few lines of code in the PlayerBullet class.
Code Listing 32: Ghosts Destroyed Leaderboard Event
public delegate void GhostDestroyedEvent(); public static event GhostDestroyedEvent GhostDestroyed; //Add this after the Destroy(gameObject) line in the OnTriggerEnter2d method. GhostDestroyed(); |
The Player Survival Time leaderboard requires an update to the PlayerController class.
Code Listing 33: Player Survival Leaderboard Event
public delegate void PlayerSurvivalTimeEvent(float time); public event PlayerSurvivalTimeEvent PlayerSurvival; private float _aliveTime; //Add this as the last line in the inner If statement in the OnTrigger2D method. if (Globals.Health <= 0) PlayerSurvival(_aliveTime); //Add this as the first line in the If statement in the Update method. _aliveTime += Time.deltaTime; |
The Accuracy leaderboard requires an update to the PlayerBullet class, as shown in Code Listing 34.
Code Listing 34: Accuracy Leaderboard Event
public delegate void AccuracyEvent(BulletEventType type); public event AccuracyEvent Accuracy; private float _aliveTime; //Add this as the last line in the inner If statement in the OnTrigger2D method. Accuracy(BulletEventType.Hit); //Add this as the after the Destroy(gameObject) line in the Update method. Accuracy(BulletEventType.Miss); |
Code Listing 35 shows the BulletEventType enum being added to the Globals file.
Code Listing 35: BulletEventType enum
public enum BulletEventType { Hit, Miss } |
The Ghost Survival Time leaderboard requires an update to the Ghost class.
Code Listing 36: Ghost Survival Time Leaderboard Event
public delegate void GhostSurvivalTimeEvent(float time); public event GhostSurvivalTimeEvent GhostSurvival; private float _aliveTime; void Start () { _aliveTime = 0.0f; } // Add this as the first lines in the If statement in the Update method. float step = Time.deltaTime; _aliveTime += step; // Add this as the last line in the else If statement in the Update method. GhostSurvival(_aliveTime); |
The LeaderboardsManager class will then hook into these events and handle them when they’re raised.
Code Listing 37: LeaderboardsManager Class
public class LeaderboardsManager : MonoBehaviour { public static LeaderboardsManager Instance; void Awake() { Instance = new LeaderboardsManager(); PlayerBullet.GhostDestroyed += GhostDestroyedHandler;
} private void GhostDestroyedHandler() { }
private void PlayerSurvivalHandler() { } private void AccuracyHandler() { } private void GhostSurvivalHandler() { } } |
Inside each event handler, you would add the code to track and/or display the necessary data.
On every platform, with the possible exception of mobile, most single-player games will only do so well without a multiplayer feature. For almost every type of game, having the ability to play with or against (or, even better, with and against) other gamers will make the game more attractive. In most cases, it’s worth the effort to implement multiplayer functionality, especially if you plan on selling your game and trying to make a living from game development.
There are several solutions for implementing multiplayer in your game:
All three solutions have some level of free functionality. Photon and Unity Networking also offer paid levels with increasing numbers of features, such as more concurrent players.
Photon is a multiplatform network library created by Exit Games. It has functionality for text, voice chat, and setting up a server, along with a matchmaking API. More than 100K developers use it. One of its main draws is that servers are handled by Unity Cloud functionality, which eliminates the need for client servers.
Lidgren is an open-source networking library. It’s not as full-featured as the other two options. It’s a message-based system which, while allowing a bit more customization, means getting it to work specifically with your game will require some effort. You’ll need to take a few steps in order to use it with Unity. Those steps are documented at https://github.com/lidgren/lidgren-network-gen3/wiki/Unity3D.
Unity has its own built-in networking functionality. Although not as old as the other two, it’s still robust enough for use with most indie-level games. The main attraction is the ease of integration with a Unity project, and that functionality, such as matchmaking, is handled by a Unity service, which you can read about at https://unity3d.com/services/multiplayer. Much of the code is also available through a Bitbucket project at https://bitbucket.org/Unity-Technologies/networking, which means you can customize the functionality to fit your unique needs.
Adding multiplayer functionality after you’ve completed development on the single-player version is usually very difficult unless your code is extremely well organized. If you think you might want to have multiplayer functionality, plan for it from the start, even if you don’t implement it immediately.
While the game we have can be challenging on hard difficulty, it can be a little boring on easy. Implementing other gameplay features could make it much more interesting. We’ll talk about a couple here, but feel free to go even further and make your game uniquely your own.
There may be times when your game will be too easy for some players on a particular difficulty level, but too hard on the next. If this happens with a lot of players, you might consider implementing a gradual increase in the difficulty at the easier level. This will make the game more attractive to players who might otherwise not buy/play it.
For our game, increasing the speed and frequency of the spawning of the ghosts makes sense. We can set this up at intervals of destruction of the ghosts by the player—for example, every 25, 50, etc. You’ll have to playtest this with players of varying ability levels in order to find the sweet spot that makes the game challenging without being so difficult that players won’t want to play it.
What was the last game you played that didn’t have some kind of power-up feature? Why didn’t it have that feature? Would that have made it a better game? While power-ups aren’t appropriate for some types of games, shooters are a prime candidate for power-ups.
Some power-ups are typical for our type of game:
Most power-ups will have some properties that are the same no matter their type—delay between spawning, an amount or time the power-up is effective, an amount the power-up increases the character or weapon it’s used for. It makes sense, then, to have a base PowerUp class to hold these properties and either inherit from this class or use some other technique to implement all the different power-ups you want in your game.
Code Listing 38: PowerUp Class
public enum PowerUpType { Health, Shield, Speed } public class PowerUp : MonoBehaviour { public PowerUpType Type; public int Amount; public float EffectiveTime; public bool Active; public Sprite Icon; public bool Rechargeable; public float RechargeTime; public bool DestoryAfterUse; private float _activeTime; private float _elapsedRechargeTime; public bool CanActivate; public PowerUp(PowerUpType type, int amount = 0, float effectiveTime = 0.0f, bool destroyAfterUse, bool rechargeable = false, bool active = false) { Type = type; Amount = amount; EffectiveTime = effectiveTime; DestroyAfterUse = destroyAfterUse; Rechargeable = rechargeable; Active = active; CanActivate = !active; } public void Activate() { Active = true; CanActivate = false; } void Update() { if (Active) { _activeTime += Time.deltaTime; if (_activeTime >= EffectiveTime) { Active = false; if (DestoryAfterUse) Destroy(this); } } else if (Rechargeable && !CanActivate) { _elapsedRechargeTime += Time.deltaTime; if (_elapsedRechargeTime >= RechargeTime) CanActivate = true; } } } |
The members are public so that the class can be used in the editor and the members set. The constructor takes values for the members as parameters for flexibility.
Because the class inherits from MonoBehaviour, the Update method can be used to handle updating the _activeTime member and disabling the power-up if the EffectiveTime has passed.
Some power-ups might stay around once they are picked up and used multiple times—possibly with a recharge time between them. Players of MMOs are used to this with abilities that have a cooldown period between uses. A couple of members can give power-ups that same functionality—the Rechargeable, RechargeTime, and _elapsedRechargeTime. The first two are public, which means they can be set in the editor if you want to put power-ups in the level. The last is only used internally in the class, so it’s private. If you want to give some kind of indicator to the player of when the power-up will be available again, you can add a public method to get the value.
If you want to get your game on a console, you will probably need to think about achievements that the game will award a player for certain accomplishments or progress made in the game. For our game, there’s not any real progression, other than surviving. In this case, it would make more sense to have the achievements be for accomplishing things such as surviving for more than a certain time or for killing a certain number of ghosts. If you add other gameplay features or implement other types of functionality we’ve addressed, the kinds of achievements you are able to award can be more varied.
Achievements for accomplishments such as killing a number of ghosts can be awarded for increments of kills—say 100, 200, etc. Achievements for surviving can be marked in a similar way—5 minutes, 10 minutes, and so on.
Each console has its own API for implementing and awarding achievements/trophies/whatever. In order to officially use their systems, you’ll have to go through the developer application process for that console. Appendix A includes URLs for the Xbox One and Playstation programs, as well as Valve’s Steamworks.
If you implement your own achievement system, you’ll want to store the information as to whether or not an achievement has been unlocked either on the player’s machine or on a server somewhere. The former will be easier and will let you avoid communicating with a server that you may or may not be responsible for maintaining and paying for, but it lacks security. Anything stored on a player’s machine will be easily editable by the player. Storing on a server will be harder to implement, but the data will be more secure, as the player only has as much access to the data as you give him.
Representing an achievement in code takes only a few pieces of data—the name and description and a date the achievement was unlocked. If the date variable is null, the achievement is still locked. Optionally, you can have a sprite represent the achievement graphically.
Code Listing 39: Achievement Class
public class Achievement { public string Name; public string Description; public DateTime? DateUnlocked; public Sprite Icon; } |
As with leaderboards, ideally you would store the achievement information for players on a server that you maintain or have a level of admin access to, so that you can maintain the information easily. Optionally, storing the information in the registry (in Windows or a system file or folder location, depending on the platform) using the PlayerPrefs class in the Unity API on the player’s machine is an easy-to-implement system. See the documentation on the class at https://docs.unity3d.com/ScriptReference/PlayerPrefs.html.
An AchievementManager class to track when a player unlocks an achievement would work almost exactly like the LeaderboardsManager class.
You should now have a complete game. What do you do with it, however? Ideally, you would compile it for every platform you can and put it in the digital distribution service (Apple AppStore, Google Play Store, etc.) for that platform, if it exists. Some platforms will take a bit of work to prep your game for—touch for mobile, for example. For a small game like this, some platforms might not be ideal. Console players will probably want a few more features than we’ve implemented. Multiplayer, in this case, would be a good feature to add. There are some things you’ll need to consider before putting the game onto a store, however:
You’ve taken your first step into game development. Hopefully, it’s been fun and made you want to learn more, so that you can create new, more complex games. Game development is something that requires almost constant learning in order to keep up with the industry. It’s a lot of work, but it’s very satisfying, especially when you see players having fun with your game. Making a living doing game development is icing on the cake.
If you really want to make game development your job, make sure you take time to play games as well as create them. It’s probably safe to say that virtually every game developer is a game player. Playing games is one of the best ways to get your creative juices going and inspire you to create awesome games. I truly wish you the best of luck on your game development journey and hope to one day play some great new games that you created.