This is the fifth installment of a tutorial series about creating a simple tower defense game. It makes it possible to create gameplay scenarios that spawn waves of varied enemies.

This tutorial is made with Unity 2018.4.6f1.

The enemy factory now defines a set of three enemies. Our current factory produces cubes at three sizes, but there's nothing stopping us from creating another factory that produces something else, like spheres at three sizes. We can change which enemies get spawned by assigning a different factory to the game, thereby switching to a different theme.

The quickest way to make all types appear in the game is to change Game.SpawnEnemy so it gets a random enemy type instead of always a medium one.

How you design the three enemy types is up to you, but for this tutorial I kept is as simple as possible. I duplicated the original enemy prefab and used it for all three sizes, only changing their material: yellow for small, blue for medium, and red for large. I didn't change the scale of the cube prefab, I instead used the scale configuration of the factory to size them. I also gave them increasing health and decreasing speed, respectively.

Add the required health parameter to Enemy.Initialize and use that to set its health, instead of deriving it from the size.

Add a type parameter to Get so it becomes possible to get a specific type of enemy, with medium as the default. Use the type to get the correct configuration—for which a separate method is convenient—and then create and initialize the enemy as before, with an added health argument.

Let's also make health configurable per enemy, as it makes sense for larger enemies to have more than smaller ones.

Adjust EnemyFactory so it supports these three enemy types instead of a single one. All three enemies require the same configuration fields, so add a nested EnemyConfig class that contains them all, then add three configuration fields of that type to the factory. As this class is solely for configuration and we won't use it anywhere else we can simply make its fields public so the factory can access them. EnemyConfig doesn't have to be public itself.

There are many ways that we could make enemies unique, but we'll keep it very simple: we classify them as either small, medium, or large. Create an EnemyType enum to indicate this.

Always spawning the same blue cube enemy isn't very interesting. The first step of creating more interesting gameplay scenarios is to support more than one kind of enemy.

Enemy Waves

The second step of creating gameplay scenarios is to no longer spawn enemies at a fixed frequency. Instead, enemies should be spawned in successive waves until the scenario is completed or the game is lost.

Spawn Sequences A single enemy wave consists of a group of enemies that are spawned one after the other, until the wave is complete. A wave can contain a mix of enemies and the delay between successive spawns can vary. To keep this simple to implement we start with a basic enemy spawn sequence that produces the same enemy type at a fixed frequency. A wave is then simply an list of such spawn sequences. Create an EnemySpawnSequence class to configure one such sequence. As it is fairly complex put it in its own file. The sequence needs to know which factory to use, which type of enemy to spawn, how many, and how quickly. To make configuration easy we'll represent the last option with a cooldown, expressing how much time must pass before the next enemy gets spawned. Note that this approach makes it possible to mix enemy factories in a wave. using UnityEngine; [System.Serializable] public class EnemySpawnSequence { [SerializeField] EnemyFactory factory = default; [SerializeField] EnemyType type = EnemyType.Medium; [SerializeField, Range(1, 100)] int amount = 1; [SerializeField, Range(0.1f, 10f)] float cooldown = 1f; }

Waves A wave is just an array of spawn sequences. Create an EnemyWave asset type for it, which starts with a single default sequence. using UnityEngine; [CreateAssetMenu] public class EnemyWave : ScriptableObject { [SerializeField] EnemySpawnSequence[] spawnSequences = { new EnemySpawnSequence() }; } Now we can design enemy waves. For example, I created a wave that spawns a bunch of cube enemies, starting with ten small ones at two per second, followed by five medium once per second, and finally a single large with a five-second cooldown. A wave of cubes, increasing in size. Could we add a delay between sequences? You could do that indirectly. For example, to put a four-second delay between the small and medium cubes, reduce the amount of the small cubes by one and insert a sequence for a single small cube after it that has a four-second cooldown. Four-second delay between small and medium.

Scenarios A gameplay scenario is created from a sequence of waves. Create a GameScenario asset type for that, with a single wave array, then use it to design a scenario. using UnityEngine; [CreateAssetMenu] public class GameScenario : ScriptableObject { [SerializeField] EnemyWave[] waves = {}; } For example, I created a scenario with two small-medium-large waves, first with cubes and then with spheres. Scenario with two SML waves.

Progressing Through a Sequence The asset types are used to design scenarios, but as assets they're meant to contain data that doesn't change while the game is playing. But to progress through a scenario we have to keep track of its state somehow. One way to do this would be to duplicate the asset when used in play and have the duplicate keep track of its state. But we don't need to duplicate the entire asset, all we need is the state and a reference to the asset. So let's create a separate State class, first for EnemySpawnSequence . As it applies to the sequence only, make it a nested class. It's only valid when it has a reference to its sequence, so give it a constructor method with a sequence parameter. Nested state type references its sequence. public class EnemySpawnSequence { … public class State { EnemySpawnSequence sequence; public State (EnemySpawnSequence sequence) { this.sequence = sequence; } } } Whenever we want to begin progressing through a sequence, we need to get a new state instance for it. Add a Begin method to the sequence that constructs the state and returns it. That makes it the responsibility of whoever invoked Begin to hold on to the state, while the sequence itself remains stateless. It would even be possible to progress through the same sequence multiple times in parallel. public class EnemySpawnSequence { … public State Begin () => new State(this); public class State { … } } To make the state survive hot reloads in the editor it needs to be serializable. [System.Serializable] public class State { … } A downside of this approach is that we need to create a new state object each time a sequence is started. We can avoid memory allocations by making it as a struct instead of a class. This is fine as long as the state remains small. Just be aware that the state is a value type. Passing it around will copy it, so keep track of it in a single place. [System.Serializable] public struct State { … } The state of a sequence consists of just two things: the spawned enemy count and the cooldown progression. Add a Progress method that increases the cooldown by the time delta and then drops it back down if it reached the configured value, just like the spawn progression in Game.Update . Increment the count each time that happens. Also, the cooldown must start at its maximum value so the sequence spawns without initial delay. int count; float cooldown; public State (EnemySpawnSequence sequence) { this.sequence = sequence; count = 0; cooldown = sequence.cooldown; } public void Progress () { cooldown += Time.deltaTime; while (cooldown >= sequence.cooldown) { cooldown -= sequence.cooldown; count += 1; } } State holds only required data. Can we access EnemySpawnSequence.cooldown in State ? Yes, because State is defined in the same scope. Nested types thus know about the private members of their containing type. Progression should continue until the desired amount of enemies have been spawned and the cooldown is complete. At that moment Progress should indicate completion, but it's likely that we end up overshooting the cooldown a bit. Thus we must return the extra time at that point, to be used to progress the next sequence. To make that work we have to turn the time delta into a parameter. We also have to indicate that we're not yet finished, which we can do by returning a negative value. public float Progress ( float deltaTime ) { cooldown += deltaTime ; while (cooldown >= sequence.cooldown) { cooldown -= sequence.cooldown; if (count >= sequence.amount) { return cooldown; } count += 1; } return -1f; }

Spawning Enemies Anywhere To make it possible for sequences to spawn enemies we'll convert Game.SpawnEnemy into another public static method. public static void SpawnEnemy ( EnemyFactory factory, EnemyType type ) { GameTile spawnPoint = instance. board.GetSpawnPoint( Random.Range(0, instance. board.SpawnPointCount) ); Enemy enemy = factory.Get( type ); enemy.SpawnOn(spawnPoint); instance. enemies.Add(enemy); } As Game will no longer spawn enemies itself we can remove its enemy factory, spawn speed, spawn progress, and the spawning code from Update . //[SerializeField] //EnemyFactory enemyFactory = default; … //[SerializeField, Range(0.1f, 10f)] //float spawnSpeed = 1f; //float spawnProgress; … void Update () { … //spawnProgress += spawnSpeed * Time.deltaTime; //while (spawnProgress >= 1f) { // spawnProgress -= 1f; // SpawnEnemy(); //} … } Invoke Game.SpawnEnemy in EnemySpawnSequence.State.Progress after increasing its count. public float Progress (float deltaTime) { cooldown += deltaTime; while (cooldown >= sequence.cooldown) { … count += 1; Game.SpawnEnemy(sequence.factory, sequence.type); } return -1f; }

Progressing Through a Wave We use the same approach for progressing through a sequence to progress through an entire wave. Give EnemyWave its own Begin method that returns a new instance of a nested State struct. In this case the state contains the wave index and the state of the active sequence, which we initialize by beginning the first sequence. Wave state, containing sequence state. public class EnemyWave : ScriptableObject { [SerializeField] EnemySpawnSequence[] spawnSequences = { new EnemySpawnSequence() }; public State Begin() => new State(this); [System.Serializable] public struct State { EnemyWave wave; int index; EnemySpawnSequence.State sequence; public State (EnemyWave wave) { this.wave = wave; index = 0; Debug.Assert(wave.spawnSequences.Length > 0, "Empty wave!"); sequence = wave.spawnSequences[0].Begin(); } } } Give EnemyWave.State a Progress method as well, using the same approach as before, with a few changes. Start with progressing the active sequence and replacing the time delta with the result of that invocation. As long as there is time remaining, move on to the next sequence if available and progress it. If no sequences remain then return the remaining time, otherwise return a negative value. public float Progress (float deltaTime) { deltaTime = sequence.Progress(deltaTime); while (deltaTime >= 0f) { if (++index >= wave.spawnSequences.Length) { return deltaTime; } sequence = wave.spawnSequences[index].Begin(); deltaTime = sequence.Progress(deltaTime); } return -1f; }

Progressing Through a Scenario Give GameScenario the same treatment. In this case the state contains the wave index and the active wave state. public class GameScenario : ScriptableObject { [SerializeField] EnemyWave[] waves = {}; public State Begin () => new State(this); [System.Serializable] public struct State { GameScenario scenario; int index; EnemyWave.State wave; public State (GameScenario scenario) { this.scenario = scenario; index = 0; Debug.Assert(scenario.waves.Length > 0, "Empty scenario!"); wave = scenario.waves[0].Begin(); } } } As we're at the top level, the Progress method doesn't require a parameter and we can directly use Time.deltaTime . We don't need to return any remaining time, but do need to indicate whether the scenario is finished or not. Return false when we've finished the final wave and true otherwise to indicate that the scenario is still active. public bool Progress () { float deltaTime = wave.Progress(Time.deltaTime); while (deltaTime >= 0f) { if (++index >= scenario.waves.Length) { return false; } wave = scenario.waves[index].Begin(); deltaTime = wave.Progress(deltaTime); } return true; }