Vampire Survivors Part 21

Creating a Rogue-like (like Vampire Survivors) in Unity — Part 21: Upgrading the Enemy Spawn System

This article is a part of the series:
Creating a Rogue-like Shoot 'Em Up (like Vampire Survivors) in Unity

This article will be free until the accompanying video is released.

In Part 7 of our series, we created an enemy spawning system so that we had a way to generate enemies and run the core gameplay loop of our game. The enemy spawning system that we created then had a couple of limitations. For example, it was not possible to spawn the infamous Flower Wall that you saw in the Vampire Survivors game.

Flower Wall
By the end of this tutorial, you will be able to do this.

Hence, we’re setting out to improve our enemy spawning system so that this sort of spawn becomes possible.

  1. Limitations of the existing EnemySpawner script
    1. The stored Wave data is not easily portable
    2. The EnemySpawner component is not self-documenting
    3. Enemy spawn points are hardcoded (and not adaptive)
  2. Replacing the EnemySpawner
    1. Creating the WaveData scriptable object
    2. The new replacement: SpawnManager
    3. Explaining the SpawnManager script
    4. Why we have the CanSpawn() and HasWaveEnded() functions?
    5. Other functions in SpawnManager
    6. Setting up the Spawn Manager
  3. Update the enemies’ behaviour scripts
    1. Adding a counter in EnemyStats
    2. Updating the EnemyMovement script
    3. Explaining the EnemyMovement script
    4. Updating the DropRateManager script
  4. Removing the EnemyScriptableObject

1. Limitations of the existing EnemySpawner script

Since we are looking at adding some major functionalities to our enemy spawning system, it makes sense for us to also identify shortcomings with our current enemy spawning system, as we can also take this chance to code in some improvements to the script.

a. The stored Wave data is not easily portable

In our current EnemySpawner script, we enter the wave data of our enemies into the Enemy Spawner component directly.

EnemySpawner wave data
We enter the wave data directly into the array.

At first glance, this seems to be a totally fine way to store wave data, but there are a few drawbacks with it:

  1. The data that you have is not reusable. For example, if you want to have a wave of Bats at 5 minutes, and then another wave of bats at 15 minutes, you will have to enter 2 identical entries into your Waves array, which is not space efficient because you are repeating the same set of data twice.
Accidentally deleting wave data
It is not difficult to delete all of your wave data by accident.
  1. It is much easier to accidentally delete or accidentally change variables attached to GameObjects. For example, if you set the number of items in the Waves field to 0 by accident, this will delete all of your enemy wave data.

A simple solution to the issues presented here is to have the wave data be stored as scriptable objects instead, just like our weapons and passives.

b. The EnemySpawner component is not self-documenting

The 2nd problem with the Enemy Spawner component is that it is pretty difficult to understand what some of the properties do. This is primarily because some of the variables that are exposed on the component, such as Current Wave Count, Enemies Alive and the Spawn Count property inside each wave, are not meant to be adjusted manually.

The Current Wave Count property, for example, is updated by the script itself, and should be left at a value of 0 when we are tweaking the component for our game.

In a larger game development environment (or any kind of application development environment), where you have many people working on the same project, this is not ideal. Especially when working in large groups, it is good for us to aim to have our code components be self-documenting, which means that it is easy for someone else using our components to understand what it does at a glance.

For example, if you look at the Rigidbody or Rigidbody2D components in Unity, just by looking at the properties, you can get a rough idea of what properties you need to tweak to get it to work the way you want:

Rigidbody Collision Detection field
You also won’t find any properties that are not meant to be tweaked by the user.

We can’t say the same for our Enemy Spawner component, because a lot of variables that are meant to be used by the script itself are exposed as public variables:

EnemySpawner wave data
How are you supposed to know that the Spawn Count attribute is not meant to be manually tweaked?

c. Enemy spawn points are hardcoded (and not adaptive)

The 3rd issue we have with the current enemy spawner, is that the spawn points for the enemies are manually set using empty GameObjects.

vampire survivors sp
Remember these?

While these work fine as long as you place them outside of your camera’s view area, it presents a problem when you play the game with a larger screen resolution. This is because a larger screen resolution means a larger camera view area, and a larger camera view area means that the spawn points may be within the camera’s view area, as opposed to being outside.

If we want our game to be playable in many different screen resolutions (i.e. be adaptive), then we will need to code the enemy spawn system so that it is able to spawn enemies right outside of the camera’s view area without us using preset GameObjects to set the position.

2. Replacing the EnemySpawner

To this end, we will set out to create a new script that is meant to replace the EnemySpawner. This is not going to be as big of an overhaul as the one to our weapon system — a lot of the core functionalities are going to behave the same. But because a lot of code is going to be changed, it is much easier to start with an entirely new component.

To begin, let’s set our old EnemySpawner script to be obsolete:

EnemySpawner.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Obsolete("Replaced by the Spawn Manager.")]
public class EnemySpawner : MonoBehaviour
{
    [System.Serializable]
    public class Wave
    {
        public string waveName;
        public List<EnemyGroup> enemyGroups;    //A list of groups of enemies to spawn in this wave
        public int waveQuota;   //The total number of enemies to spawn in this wave
        public float spawnInterval; //The interval at which to spawn enemies
        public int spawnCount;  //The number of enemies already spawned in this wave
    }

    [System.Serializable]
    public class EnemyGroup
    {
        public string enemyName;
        public int enemyCount;  //The number of enemies of this type to spawn in this wave
        public int spawnCount;  //The number of enemies of this type already spawned in this wave
        public GameObject enemyPrefab;
    }

    public List<Wave> waves;    //A list of all the waves in the game
    public int currentWaveCount;    //The index of the current wave [Remember, a list starts from 0]

    [Header("Spawner Attributes")]
    float spawnTimer; //Timer used to determine when to spawn the next enemy
    public int enemiesAlive;
    public int maxEnemiesAllowed; //The maximum number of enemies allowed on the map at once
    public bool maxEnemiesReached = false;  //A flag indicating if the maximum number of enemies has been reached
    public float waveInterval;  //The interval between each wave
    bool isWaveActive = false;

    [Header("Spawn Positions")]
    public List<Transform> relativeSpawnPoints; //A list to store all the relative spawn points of enemies

    Transform player;

    void Start()
    {
        player = FindObjectOfType<PlayerStats>().transform;
        CalculateWaveQuota();
    }

    void Update()
    {
        // If we have finished all the waves in the game, and
        // if the current wave has also finished spawning its enemies.
        if (currentWaveCount < waves.Count && waves[currentWaveCount].spawnCount == 0 && !isWaveActive)  //Check if the wave has ended and the next wave should start
        {
            StartCoroutine(BeginNextWave());
        }

        spawnTimer += Time.deltaTime;

        //Check if it's time to spawn the next enemy
        if (spawnTimer >= waves[currentWaveCount].spawnInterval)
        {
            spawnTimer = 0f;
            SpawnEnemies();
        }
    }

    IEnumerator BeginNextWave()
    {
        isWaveActive = true;

        //Wait for `waveInterval` seconds before starting the next wave.
        yield return new WaitForSeconds(waveInterval);

        //If there are more waves to start after the current wave, move on to the next wave
        if (currentWaveCount < waves.Count - 1)
        {
            isWaveActive = false;
            currentWaveCount++;
            CalculateWaveQuota();
        }
    }

    void CalculateWaveQuota()
    {
        int currentWaveQuota = 0;

        foreach (var enemyGroup in waves[currentWaveCount].enemyGroups)
        {
            currentWaveQuota += enemyGroup.enemyCount;
        }

        waves[currentWaveCount].waveQuota = currentWaveQuota;
        Debug.LogWarning(currentWaveQuota);
    }

    /// <summary>
    /// This method will stop spawning enemies if the amount of enemies on the map is maxmimum.
    /// The method will only spawn enemies in a particular wave until it is time for the next wave's enemies to be spawned
    /// </summary>
    void SpawnEnemies()
    {
        // Check if the minimum number of enemies in the wave have been spawned
        if (waves[currentWaveCount].spawnCount < waves[currentWaveCount].waveQuota && !maxEnemiesReached)
        {
            // Spawn each type of enemy until the quota is filled
            foreach (var enemyGroup in waves[currentWaveCount].enemyGroups)
            {
                // Check if the minimum number of enemies of this type have been spawned
                if (enemyGroup.spawnCount < enemyGroup.enemyCount)
                {
                    // Spawn the enemy at a random position close to the player
                    Instantiate(enemyGroup.enemyPrefab, player.position + relativeSpawnPoints[Random.Range(0, relativeSpawnPoints.Count)].position, Quaternion.identity);

                    enemyGroup.spawnCount++;
                    waves[currentWaveCount].spawnCount++;
                    enemiesAlive++;

                    // Limit the number of enemies that can be spawned at once
                    if (enemiesAlive >= maxEnemiesAllowed)
                    {
                        maxEnemiesReached = true;
                        return;
                    }
                }
            }
        }
    }

    // Call this function when an enemy is killed
    public void OnEnemyKilled()
    {
        //Decrement the number of enemies alive
        enemiesAlive--;

        //Reset the maxEnemiesReached flag if the number of enemies alive has dropped below the maximum allowed
        if (enemiesAlive < maxEnemiesAllowed)
        {
            maxEnemiesReached = false;
        }
    }
}

a. Creating the WaveData scriptable object

Note: In our video, we store all the new scripts here inside of a new folder Scripts/Spawning.

The first thing we will do is create a WaveData scriptable object, which will be a part of a hierarchy of classes designed to store spawn data. The class hierarchy will be as follows:

Class hierarchy for spawn data
We will just be creating SpawnData and WaveData for now.

You might be wondering — we are just creating a scriptable object to store wave data right? Why do we have to create such a complex hierarchy of classes?

This is because, later on in this part, we will also be creating an event system that spawns enemy configurations like the Flower Wall and the Bat Swarm. Our wave data scriptable object system has to be able to accommodate these as well, which is why we create a superclass SpawnData above our WaveData.

Let’s create our SpawnData first. This is a class that declares the basic variables that a wave will need to store:

SpawnData.cs

using UnityEngine;

public abstract class SpawnData : ScriptableObject
{
    [Tooltip("A list of all possible GameObjects that can be spawned.")]
    public GameObject[] possibleSpawnPrefabs = new GameObject[1];

    [Tooltip("Time between each spawn (in seconds). Will take a random number between X and Y.")]
    public Vector2 spawnInterval = new Vector2(2, 3);

    [Tooltip("How many enemies are spawned per interval?")]
    public Vector2Int spawnsPerTick = new Vector2Int(1, 1);

    [Tooltip("How long (in seconds) this will spawn enemies for.")]
    [Min(0.1f)] public float duration = 60;

    // Returns an array of prefabs that we should spawn.
    // Takes an optional parameter of how many enemies are on the screen at the moment.
    public virtual GameObject[] GetSpawns(int totalEnemies = 0)
    {
        // Determine how many enemies to spawn.
        int count = Random.Range(spawnsPerTick.x, spawnsPerTick.y);

        // Generate the result.
        GameObject[] result = new GameObject[count];
        for (int i = 0; i < count; i++)
        {
            // Randomly picks one of the possible spawns and inserts it
            // into the result array.
            result[i] = possibleSpawnPrefabs[Random.Range(0, possibleSpawnPrefabs.Length)];
        }

        return result;
    }

    // Get a random spawn interval between the min and max values.
    public virtual float GetSpawnInterval()
    {
        return Random.Range(spawnInterval.x, spawnInterval.y);
    }
}

Then, using the SpawnData class, we will create the WaveData class. Because a lot of the variables that the WaveData needs is already declared in SpawnData, our WaveData script is a lot shorter than what it would have been.

WaveData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "Wave Data", menuName = "2D Top-down Rogue-like/Wave Data")]
public class WaveData : SpawnData
{

    [Header("Wave Data")]

    [Tooltip("If there are less than this number of enemies, we will keep spawning until we get there.")]
    [Min(0)] public int startingCount = 0;

    [Tooltip("How many enemies can this wave spawn at maximum?")]
    [Min(1)] public uint totalSpawns = uint.MaxValue;

    [System.Flags] public enum ExitCondition { waveDuration = 1, reachedTotalSpawns = 2 }
    [Tooltip("Set the things that can trigger the end of this wave")]
    public ExitCondition exitConditions = (ExitCondition)1;
    
    [Tooltip("All enemies must be dead for the wave to advance.")]
    public bool mustKillAll = false;

    [HideInInspector] public uint spawnCount;  //The number of enemies already spawned in this wave

    // Returns an array of prefabs that this wave can spawn.
    // Takes an optional parameter of how many enemies are on the screen at the moment.
    public override GameObject[] GetSpawns(int totalEnemies = 0)
    {
        // Determine how many enemies to spawn.
        int count = Random.Range(spawnsPerTick.x, spawnsPerTick.y);

        // If we have less than <minimumEnemies> on the screen, we will 
        // set the count to be equals to the number of enemies to spawn to
        // populate the screen until it has <minimumEnemies> within.
        if (totalEnemies + count < startingCount)
            count = startingCount - totalEnemies;

        // Generate the result.
        GameObject[] result = new GameObject[count];
        for(int i = 0; i < count; i++)
        {
            // Randomly picks one of the possible spawns and inserts it
            // into the result array.
            result[i] = possibleSpawnPrefabs[Random.Range(0, possibleSpawnPrefabs.Length)];
        }

        return result;
    }
}

b. The WaveData class explained

Being a scriptable object, the WaveData class is designed to allow us to create new assets — much like our WeaponData or PassiveData objects — which we can use to store data about enemy waves in our spawning system.

WaveData scriptable object
How the WaveData scriptable object looks like.

As expected, the WaveData class is designed to store information about a wave of enemies. It bears mentioning that when I say wave, I don’t mean only a single mob of enemies. Instead, here, a “wave” means that for a fixed amount of time, only the enemies inside of the Possible Spawn Prefabs array spawn, according to the parameters of the WaveData object.

Below is a table describing the various properties in a WaveData scriptable object, and what they control:

AttributeDescription
Possible Spawn PrefabsA list of all possible GameObjects that can be spawned by this wave. To control the probability of each spawn, you can add certain prefabs to the list multiple times. For example, in the above image, the Bat has a 75% chance of spawning, while the Red Bat only has 25% chance. This is because the Bat prefab was put into this array 3 times, while the Red Bat prefab was only put in once.
Spawn IntervalThis controls the amount of time between each set of spawns. For example, if this is set to 5 to 10, then a new set of enemies will spawn every 5 to 10 seconds.
Spawns Per TickThis controls how many enemies spawn in each set of spawns. For example, if this is set to 2 to 3, then every new set of enemies spawned in this wave will have 2 to 3 enemies.
DurationThis controls how long the wave will be active for. A wave with a duration of 60, for example, will be active for 60 seconds.
Wave Data
Starting CountIf the map has less than this number of enemies on the map when this wave starts, then the wave will keep spawning enemies until it reaches this number.
Total SpawnsThe total number of enemies this wave is allowed to spawn. By default, this is set to a very high value so that it will never be reached.
Exit ConditionsThis is a list of conditions that will cause the wave to end (thereby activating the next wave). There are currently only 2 exit conditions:
  1. Wave Duration: Triggered when the wave has been running for Duration seconds, and;
  2. Reached Total Spawns: Triggered when the wave has spawned Total Spawns number of enemies.
By default, the waves in Vampire Survivors only end after running a certain duration, but this option is here to allow flexibility in terms of how the waves will progress.

On top of the variables in the WaveData class, the SpawnData class also defines 2 functions that will later make it easy for us to retrieve data from the WaveData / SpawnData scriptable objects in our new SpawnManager script (which will be taking over EnemySpawner as the script that spawns enemies).

  • GetSpawnInterval(): This is a function that gets a number between the X and Y values of the Spawn Interval attribute. It basically is a function that is meant to make it easy for us to retrieve a random value from the WaveData later on.
  • GetSpawns(): This is a function that generates the group of enemies to be spawned, based on the settings in Possible Spawn Prefabs and Spawns Per Tick. For example, if we have Spawns Per Tick of 5 to 10, this function will randomly pick 5 to 10 enemies from Possible Spawn Prefabs and return these picked enemies as an array of GameObjects. It is meant to make it easy for the SpawnManager to generate the list of enemies to spawn later on.

Now that we have the WaveData set up, it is time for us to create our new SpawnManager script.

c. The new replacement: SpawnManager

Now that we have our scriptable object set up for storing our wave data, let’s create the new spawner script that is capable of reading the WaveData scriptable objects.

SpawnManager.cs

using UnityEngine;

public class SpawnManager : MonoBehaviour
{
    
    int currentWaveIndex; //The index of the current wave [Remember, a list starts from 0]
    int currentWaveSpawnCount = 0; // Tracks how many enemies current wave has spawned.

    public WaveData[] data;
    public Camera referenceCamera;

    [Tooltip("If there are more than this number of enemies, stop spawning any more. For performance.")]
    public int maximumEnemyCount = 300;
    float spawnTimer; // Timer used to determine when to spawn the next group of enemy.
    float currentWaveDuration = 0f;

    public static SpawnManager instance;

    void Start()
    {
        if(instance) Debug.LogWarning("There is more than 1 Spawn Manager in the Scene! Please remove the extras.");
        instance = this;
    }

    void Update()
    {
        // Updates the spawn timer at every frame.
        spawnTimer -= Time.deltaTime;
        currentWaveDuration += Time.deltaTime;

        if(spawnTimer <= 0) {
            // Check if we are ready to move on to the new wave.
            if(HasWaveEnded())
            {
                currentWaveIndex++;
                currentWaveDuration = currentWaveSpawnCount = 0;

                // If we have gone through all the waves, disable this component.
                if (currentWaveIndex >= data.Length)
                {
                    Debug.Log("All waves have been spawned! Shutting down.", this);
                    enabled = false;
                }

                return;
            }

            // Do not spawn enemies if we do not meet the conditions to do so.
            if (!CanSpawn())
            {
                spawnTimer += data[currentWaveIndex].GetSpawnInterval();
                return;
            }

            // Get the array of enemies that we are spawning for this tick.
            GameObject[] spawns = data[currentWaveIndex].GetSpawns(EnemyStats.count);

            // Loop through and spawn all the prefabs.
            foreach(GameObject prefab in spawns)
            {
                // Stop spawning enemies if we exceed the limit.
                if (!CanSpawn()) continue;

                // Spawn the enemy.
                Instantiate(prefab, GeneratePosition(), Quaternion.identity);
                currentWaveSpawnCount++;
            }
            
            // Regenerates the spawn timer.
            spawnTimer += data[currentWaveIndex].GetSpawnInterval();
        }
    }

    // Do we meet the conditions to be able to continue spawning?
    public bool CanSpawn()
    {
        // Don't spawn anymore if we exceed the max limit.
        if (HasExceededMaxEnemies()) return false;

        // Don't spawn if we exceeded the max spawns for the wave.
        if (instance.currentWaveSpawnCount > instance.data[instance.currentWaveIndex].totalSpawns) return false;

        // Don't spawn if we exceeded the wave's duration.
        if (instance.currentWaveDuration > instance.data[instance.currentWaveIndex].duration) return false;
        return true;
    }

    // Allows other scripts to check if we have exceeded the maximum number of enemies.
    public static bool HasExceededMaxEnemies()
    {
        if (!instance) return false; // If there is no spawn manager, don't limit max enemies.
        if(EnemyStats.count > instance.maximumEnemyCount) return true;
        return false;
    }

    public bool HasWaveEnded()
    {
        WaveData currentWave = data[currentWaveIndex];

        // If waveDuration is one of the exit conditions, check how long the wave has been running.
        // If current wave duration is not greater than wave duration, do not exit yet.
        if ((currentWave.exitConditions & WaveData.ExitCondition.waveDuration) > 0)
            if (currentWaveDuration < currentWave.duration) return false;

        // If reachedTotalSpawns is one of the exit conditions, check if we have spawned enough
        // enemies. If not, return false.
        if ((currentWave.exitConditions & WaveData.ExitCondition.reachedTotalSpawns) > 0)
            if (currentWaveSpawnCount < currentWave.totalSpawns) return false;

        // Otherwise, if kill all is checked, we have to make sure there are no more enemies first.
        if (currentWave.mustKillAll && EnemyStats.count > 0)
            return false;

        return true;
    }

    void Reset()
    {
        referenceCamera = Camera.main;
    }

    // Creates a new location where we can place the enemy at.
    public static Vector3 GeneratePosition()
    {
        // If there is no reference camera, then get one.
        if(!instance.referenceCamera) instance.referenceCamera = Camera.main;

        // Give a warning if the camera is not orthographic.
        if(!instance.referenceCamera.orthographic)
            Debug.LogWarning("The reference camera is not orthographic! This will cause enemy spawns to sometimes appear within camera boundaries!");

        // Generate a position outside of camera boundaries using 2 random numbers.
        float x = Random.Range(0f, 1f), y = Random.Range(0f, 1f);

        // Then, randomly choose whether we want to round the x or the y value.
        switch(Random.Range(0, 2)) {
            case 0: default:
                return instance.referenceCamera.ViewportToWorldPoint( new Vector3(Mathf.Round(x), y) );
            case 1:
                return instance.referenceCamera.ViewportToWorldPoint( new Vector3(x, Mathf.Round(y)) );
        }
    }

    // Checking if the enemy is within the camera's boundaries.
    public static bool IsWithinBoundaries(Transform checkedObject)
    {
        // Get the camera to check if we are within boundaries.
        Camera c = instance && instance.referenceCamera ? instance.referenceCamera : Camera.main;

        Vector2 viewport = c.WorldToViewportPoint(checkedObject.position);
        if (viewport.x < 0f || viewport.x > 1f) return false;
        if (viewport.y < 0f || viewport.y > 1f) return false;
        return true;
    }
}

In this new SpawnManager script, we make it a point to only expose the variables that are meant to be set by the level designer (i.e. the person who is editing the Scene).

d. Explaining the SpawnManager script

As a component, the new SpawnManager script is much simpler than the old EnemySpawner script. It only has 3 properties for you to adjust:

New SpawnManager
So there should be less confusion about how to use it.

Below is a table describing what these properties do:

AttributeDescription
DataAn array of all the waves that the SpawnManager should cycle through for the duration of the game.
Reference CameraAs the SpawnManager will always spawn enemies right outside of the camera, this attribute controls which camera is used to determine which parts of the map are considered to be “outside of the camera”. When you attach this component to a GameObject, the Reference Camera is always automatically set to the main camera in your Scene.
Maximum Enemy CountIn Vampire Survivors, there is a hard limit of 300 enemies on screen at all times. If you have more than 300 of them, no more new enemies will spawn. If you want to remove this limit, set this value to 0.

To understand what is going on in the SpawnManager script, the most important function to look at is the Update() function, which does the following:

  1. The SpawnManager script has 2 private variables that are very important to its functioning:
    • spawnTimer tracks the “cooldown” of the SpawnManager. Whenever it is cooled down, a new group of enemies is spawned.
    • currentWaveDuration tracks how long the wave has been running. Once the wave has run for a duration longer than the Duration attribute of the current wave, the SpawnManager will automatically move on to the next wave.
  2. Every time the Update() function runs, the spawnTimer is reduced by Time.deltaTime, and the currentWaveDuration is increased by Time.deltaTime. This updates the cooldown and the time that the wave is currently running for.
  3. If spawnTimer is currently less than 0, this means it is time to spawn the next group of enemies. Before we spawn the next group, we check if the wave has ended using the HasWaveEnded() function (explained below later). If the wave has ended, we will try to advance the SpawnManager to the next wave.
  4. If the wave has not ended, we first run the CanSpawn() function to check if we are allowed to spawn more enemies. If we are not allowed to, then we reset the spawnTimer to stop any more enemies from spawning.
  5. Finally, if the code is still running here, we can spawn enemies. We will access the WaveData object of the current wave and call GetSpawns() to generate a list of the enemies we need to spawn, then run a for loop to spawn them. Finally, we reset spawnTimer to a random number between the Spawn Interval values.

e. Why we have the CanSpawn() and HasWaveEnded() functions?

One thing that you might be wondering about our SpawnManager script, is why created the CanSpawn() and HasWaveEnded() functions, instead of writing the conditions directly in the Update() function. There are 2 reasons for this:

  1. It makes the code more readable. If you look at either of those functions, you will see a lot of conditional logic within, which makes it hard to read and discern what the logic is trying to do. By wrapping them into functions, you make the code a lot more readable; and because the code is grouped, it also has the effect of…
  2. Breaking the code into simpler parts, which is always beneficial, because breaking a bigger problem down into smaller parts can make it easier to think about it.

Can you imagine, for instance, if you had to write all the conditions for HasWaveEnded() in the snippet below? Makes the code so much more complicated, doesn’t it?

if(spawnTimer <= 0) {
    // Check if we are ready to move on to the new wave.
    if(HasWaveEnded())
    {
        currentWaveIndex++;
        currentWaveDuration = currentWaveSpawnCount = 0;

        // If we have gone through all the waves, disable this component.
        if (currentWaveIndex >= data.Length)
        {
            Debug.Log("All waves have been spawned! Shutting down.", this);
            enabled = false;
        }
        ...

f. Other functions in SpawnManager

On top of the aforementioned functions above, the SpawnManager script also contains 3 static functions:

  1. HasExceededMaxEnemies(): Which is used by CanSpawn() to check if we have more than the maximum number of enemies in the map currently.
  2. GeneratePosition(): Which is used by Update() to randomly pick a position just outside of the camera to spawn the enemy.
  3. IsWithinBoundaries(): Which can be used by any script or GameObject to see if it is within the boundaries of the Reference Camera currently.

Of the 3 functions, (i) and (ii) are used by SpawnManager itself to perform its role, whereas (iii) is used by our enemy scripts to determine whether it is within the boundaries of the camera currently.

g. Setting up the Spawn Manager

Once you are done adding the script to your project, you can hook up the Spawn Manager component to your old Enemy Spawner GameObject, and disable the Enemy Spawner component. Afterwards, create some new WaveData files containing the waves that you want your Spawn Manager to spawn, and add it to the Data field of the Spawn Manager.

Replacing the EnemySpawner
Out with the old, in with the new.

You can also remove the old spawn points from the Enemy Spawner GameObject, as they are no longer needed by the new SpawnManager.

EnemySpawner spawn points
The old spawn points under Enemy Spawner are no longer needed.

You’ll find, however, that after adding the SpawnManager script, your Console will show some errors. This is because we’ll need to…

3. Update the enemies’ behaviour scripts

In our new SpawnManager script above, there are some parts that refer to a static variable called EnemyStats.count, which doesn’t currently exist. Hence, we’ll first need to update our EnemyStats script to have this variable.

a. Adding a counter in EnemyStats

We’ll add a static count variable to our EnemyStats script, which gets increased by 1 in the Awake() function (i.e. every time a new EnemyStats component is created). This value also gets decreased by 1 in the OnDestroy() function (i.e. every time an EnemyStats component is destroyed).

This essentially creates a variable which can be accessed by typing EnemyStats.count anywhere in our code, which gives us the number of enemies currently on the screen. Since SpawnManager uses this EnemyStats.count variable, making this change will fix the errors that appeared after adding the script; and if you’ll like to know how static variables work, we have an article below that explains this in more detail.

On top of that, we also had a despawn and respawn system in EnemyStats that detects if an enemy is too far away from the player, and teleports it closer to the player. We’ll remove this from the EnemyStats script as well (see the removed Update() and ReturnEnemy() functions below), and create a more advanced version of this in EnemyMovement later on.

EnemyStats.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : MonoBehaviour
{
    public EnemyScriptableObject enemyData;

    //Current stats
    public float currentMoveSpeed;
    public float currentHealth;
    public float currentDamage;

    public float despawnDistance = 20f;
    Transform player;

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1,0,0,1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    Color originalColor;
    SpriteRenderer sr;
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {

        count++;

        //Assign the vaiables
        currentMoveSpeed = enemyData.MoveSpeed;
        currentHealth = enemyData.MaxHealth;
        currentDamage = enemyData.Damage;
    }

    void Start()
    {
        player = FindObjectOfType<PlayerStats>().transform;
        sr = GetComponent<SpriteRenderer>();
        originalColor = sr.color;

        movement = GetComponent<EnemyMovement>();
    }

    void Update()
    {
        if (Vector2.Distance(transform.position, player.position) >= despawnDistance)
        {
            ReturnEnemy();
        }
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        currentHealth -= dmg;
        StartCoroutine(DamageFlash());

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);

        // Apply knockback if it is not zero.
        if(knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }

        // Kills the enemy if the health drops below zero.
        if (currentHealth <= 0)
        {
            Kill();
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        sr.color = damageColor;
        yield return new WaitForSeconds(damageFlashDuration);
        sr.color = originalColor;
    }

    public void Kill()
    {
        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = sr.color.a;

        // This is a loop that fires every frame.
        while(t < deathFadeTime) {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }


    void OnCollisionStay2D(Collision2D col)
    {
        //Reference the script from the collided collider and deal damage using TakeDamage()
        if (col.gameObject.CompareTag("Player"))
        {
            PlayerStats player = col.gameObject.GetComponent<PlayerStats>();
            player.TakeDamage(currentDamage); // Make sure to use currentDamage instead of weaponData.Damage in case any damage multipliers in the future
        }
    }

    private void OnDestroy()
    {
        EnemySpawner es = FindObjectOfType<EnemySpawner>();
        if(es) es.OnEnemyKilled();
        count--;
    }

    void ReturnEnemy()
    {
        EnemySpawner es = FindObjectOfType<EnemySpawner>();
        transform.position = player.position + es.relativeSpawnPoints[Random.Range(0, es.relativeSpawnPoints.Count)].position;
    }
}

b. Updating the EnemyMovement script

Also, since we removed the despawning and respawning functionality in EnemyStats, we’ll have to implement it here in EnemyMovement. There are a few reasons for us to move the functionality to EnemyMovement instead:

  1. It is organisationally more appropriate, since the despawn and respawn functionality basically moves the enemy closer to the player, it makes more sense for EnemyMovement to handle it.
  2. The EnemyMovement script currently does very little, compared to the EnemyStats script, which also manages enemies’ health and damage functionality. By putting this in EnemyMovement, it makes it easier for us to add more features to the despawning and respawning functionality, since the script is much less complex than our EnemyStats script.

Below are the changes we’re making to the EnemyMovement script:

EnemyMovement.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyMovement : MonoBehaviour
{
    protected EnemyStats enemy;
    protected Transform player;

    protected Vector2 knockbackVelocity;
    protected float knockbackDuration;

    public enum OutOfFrameAction { none, respawnAtEdge, despawn }
    public OutOfFrameAction outOfFrameAction = OutOfFrameAction.respawnAtEdge;

    protected bool spawnedOutOfFrame = false;

    protected virtual void Start()
    {
        spawnedOutOfFrame = !SpawnManager.IsWithinBoundaries(transform);
        enemy = GetComponent<EnemyStats>();
        player = FindObjectOfType<PlayerMovement>().transform;

        // Picks a random player on the screen, instead of always picking the 1st player.
        PlayerMovement[] allPlayers = FindObjectsOfType<PlayerMovement>();
        player = allPlayers[Random.Range(0, allPlayers.Length)].transform;
    }

    protected virtual void Update()
    {
        // If we are currently being knocked back, then process the knockback.
        if(knockbackDuration > 0)
        {
            transform.position += (Vector3)knockbackVelocity * Time.deltaTime;
            knockbackDuration -= Time.deltaTime;
        }
        else
        {
            // Constantly move the enemy towards the player
            transform.position = Vector2.MoveTowards(transform.position, player.transform.position, enemy.currentMoveSpeed * Time.deltaTime);
            Move();
            HandleOutOfFrameAction();
        }
    }

    // If the enemy falls outside of the frame, handle it.
    protected virtual void HandleOutOfFrameAction() {
        // Handle the enemy when it is out of frame.
        if (!SpawnManager.IsWithinBoundaries(transform))
        {
            switch(outOfFrameAction)
            {
                case OutOfFrameAction.none: default:
                    break;
                case OutOfFrameAction.respawnAtEdge:
                    // If the enemy is outside the camera frame, teleport it back to the edge of the frame.
                    transform.position = SpawnManager.GeneratePosition();
                    break;
                case OutOfFrameAction.despawn:
                    // Don't destroy if it is spawned outside the frame.
                    if (!spawnedOutOfFrame)
                    {
                        Destroy(gameObject);
                    }
                    break;
            }
        } else spawnedOutOfFrame = false;
    }

    // This is meant to be called from other scripts to create knockback.
    public virtual void Knockback(Vector2 velocity, float duration)
    {
        // Ignore the knockback if the duration is greater than 0.
        if(knockbackDuration > 0) return;

        // Begins the knockback.
        knockbackVelocity = velocity;
        knockbackDuration = duration;
    }

    public virtual void Move()
    {
        // Constantly move the enemy towards the player
        transform.position = Vector2.MoveTowards(transform.position, player.transform.position, enemy.currentMoveSpeed * Time.deltaTime);
    }
}

c. Explaining the EnemyMovement script

The primary change to the EnemyMovement script is the addition of a property called Out Of Frame Action. This property controls what happens to the enemies when they are outside of our camera’s view.

New EnemyMovement script
This allows us to control the behaviour of the enemy when outside the camera frame.

Here is a list of what each Out Of Frame Action does:

OptionDescription
NoneNothing happens when the enemy goes out of frame.
Respawn At EdgeRespawns the enemy just at the edge when it falls out of the camera.
DespawnRemove the enemy from the game if it falls out of the camera frame.

In the updated EnemyMovement code, we move the lines responsible for the enemy’s movement into its own Move() function, and we create a new HandleOutOfFrameAction() function that checks if an enemy is within the boundaries of the camera using the SpawnManager.IsWithinBoundaries() function. The switch statement within the HandleOutOfFrameAction() function then handles the relocation of an enemy outside of the boundaries, depending on which Out Of Frame Action is selected.

Outside of the main changes above, there are also a couple of minor improvements to the EnemyMovement script:

  1. In anticipation of supporting a multiplayer version in future, the enemy will now randomly pick a PlayerMovement component to follow from the map, instead of always picking the 1st player (refer to the change in how player variable is assigned in the Start() function).
  2. A lot of the properties and methods have been made protected and virtual. This opens up the possibility of subclassing the EnemyMovement script to create different kinds of enemies. Currently, we only have the option of making enemies that follow the player, but not all enemies in Vampire Survivors do that — some hover around the player, others charge in a fixed direction, etc. These changes allow us to access or override methods in subclasses, so we can more easily make other kinds of enemy movements based on this script. You will see an example of this later on when we make a ChargingEnemyMovement script.
  3. Notice also that there is a spawnedOutOfFrame variable added to EnemyMovement. This variable is used to track whether the enemy is spawned outside of the frame. If it is, any Out Of Frame Action will not apply for it until the first time it enters the camera frame — otherwise, enemies with an Out Of Frame Action of Despawn will not be able to spawn outside of the frame!

d. Updating the DropRateManager and EnemyStats script

With the new EnemyMovement script above, if we use the despawn Out Of Frame Action to remove enemies when they move out of the screen, the enemies that are despawned will drop items.

To fix this, we will have to modify DropRateManager so that they don’t always drop items when a GameObject is destroyed. For this to work, we’ll need to add a new active variable to the DropRateManager script, so that when we deactivate it, it won’t spawn any new items:

DropRateManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DropRateManager : MonoBehaviour
{
    [System.Serializable]   //Serialize the class
    public class Drops
    {
        public string name;
        public GameObject itemPrefab;
        public float dropRate;
    }
    public bool active = false;
    public List<Drops> drops;

    void OnDestroy()
    {
        if (!active) return;
        if (!gameObject.scene.isLoaded) //Stops the spawning error from appearing when stopping play mode
        {
            return;
        }

        float randomNumber = UnityEngine.Random.Range(0f, 100f);
        List<Drops> possibleDrops = new List<Drops>();

        foreach (Drops rate in drops)
        {
            if (randomNumber <= rate.dropRate)
            {
                possibleDrops.Add(rate);
            }
        }
        //Check if there are possible drops
        if (possibleDrops.Count > 0)
        {
            Drops drops = possibleDrops[UnityEngine.Random.Range(0, possibleDrops.Count)];
            Instantiate(drops.itemPrefab, transform.position, Quaternion.identity);
        }
    }
}

Since we are also setting the active variable to false by default, we will need to have a script turn it on at the right time. To this end, we will modify the Kill() function in EnemyStats so that it turns on the active variable in DropRateManager — what this does is that it makes an enemy capable of dropping items only when they are killed.

EnemyStats.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : MonoBehaviour
{
    public EnemyScriptableObject enemyData;

    //Current stats
    public float currentMoveSpeed;
    public float currentHealth;
    public float currentDamage;

    Transform player;

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1,0,0,1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    Color originalColor;
    SpriteRenderer sr;
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {

        count++;

        //Assign the vaiables
        currentMoveSpeed = enemyData.MoveSpeed;
        currentHealth = enemyData.MaxHealth;
        currentDamage = enemyData.Damage;
    }

    void Start()
    {
        player = FindObjectOfType<PlayerStats>().transform;
        sr = GetComponent<SpriteRenderer>();
        originalColor = sr.color;

        movement = GetComponent<EnemyMovement>();
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        currentHealth -= dmg;
        StartCoroutine(DamageFlash());

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);

        // Apply knockback if it is not zero.
        if(knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }

        // Kills the enemy if the health drops below zero.
        if (currentHealth <= 0)
        {
            Kill();
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        sr.color = damageColor;
        yield return new WaitForSeconds(damageFlashDuration);
        sr.color = originalColor;
    }

    public void Kill()
    {
        // Enable drops if the enemy is killed,
        // since drops are disabled by default.
        DropRateManager drops = GetComponent<DropRateManager>();
        if(drops) drops.active = true;

        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = sr.color.a;

        // This is a loop that fires every frame.
        while(t < deathFadeTime) {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }


    void OnCollisionStay2D(Collision2D col)
    {
        //Reference the script from the collided collider and deal damage using TakeDamage()
        if (col.gameObject.CompareTag("Player"))
        {
            PlayerStats player = col.gameObject.GetComponent<PlayerStats>();
            player.TakeDamage(currentDamage); // Make sure to use currentDamage instead of weaponData.Damage in case any damage multipliers in the future
        }
    }

    private void OnDestroy()
    {
        count--;
    }
}

4. Removing the EnemyScriptableObject

Another change that we are going to make to the EnemyStats script is to remove its dependency on the EnemyScriptableObject script.

a. Updates to EnemyStats and deleting EnemyScriptableObject

It’s a really simple change — we simply remove the enemyData variable, and remove the variables that are set by the enemyData.

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : MonoBehaviour
{
    public EnemyScriptableObject enemyData;

    //Current stats
    public float currentMoveSpeed;
    public float currentHealth;
    public float currentDamage;

    Transform player;

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1,0,0,1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    Color originalColor;
    SpriteRenderer sr;
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {

        count++;

        //Assign the vaiables
        currentMoveSpeed = enemyData.MoveSpeed;
        currentHealth = enemyData.MaxHealth;
        currentDamage = enemyData.Damage;
    }

    void Start()
    {
        player = FindObjectOfType<PlayerStats>().transform;
        sr = GetComponent<SpriteRenderer>();
        originalColor = sr.color;

        movement = GetComponent<EnemyMovement>();
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        currentHealth -= dmg;
        StartCoroutine(DamageFlash());

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);

        // Apply knockback if it is not zero.
        if(knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }

        // Kills the enemy if the health drops below zero.
        if (currentHealth <= 0)
        {
            Kill();
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        sr.color = damageColor;
        yield return new WaitForSeconds(damageFlashDuration);
        sr.color = originalColor;
    }

    public void Kill()
    {
        // Enable drops if the enemy is killed,
        // since drops are disabled by default.
        DropRateManager drops = GetComponent<DropRateManager>();
        if(drops) drops.active = true;

        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = sr.color.a;

        // This is a loop that fires every frame.
        while(t < deathFadeTime) {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }


    void OnCollisionStay2D(Collision2D col)
    {
        //Reference the script from the collided collider and deal damage using TakeDamage()
        if (col.gameObject.CompareTag("Player"))
        {
            PlayerStats player = col.gameObject.GetComponent<PlayerStats>();
            player.TakeDamage(currentDamage); // Make sure to use currentDamage instead of weaponData.Damage in case any damage multipliers in the future
        }
    }

    private void OnDestroy()
    {
        count--;
    }
}

Then, we delete the EnemyScriptableObject script and remove all EnemyScriptableObject assets in our project.

Remember to delete the assets as well
Remember to delete the EnemyScriptableObject assets as well!

After doing so, remember to set the stats directly in your enemy prefabs! Otherwise, they will have no health and be unable to move!

EnemyStats not used
After you make the changes, Enemy Data should no longer be there, so you need to set the stats (red square) directly in the prefabs!

b. Why remove the dependency?

With the EnemyScriptableObject, when we want to create a new enemy type, we will need to:

  1. Create a new enemy prefab, as well as set the appropriate sprites and animations to it in its Sprite Renderer and Animator components.
  2. Create a new EnemyScriptableObject asset, and set the stats of the enemy in the asset.

The way this system is set up causes redundancy, because we can already set the enemy’s stats in the prefab itself:

EnemyStats not used
In our enemy prefabs, the stats are all set to 0, because Enemy Data is used instead to set it.

So there is no need to create an EnemyScriptableObject asset specifically for the stats, because the prefab itself is also an asset that is capable of storing values!

By forcing the EnemyScriptableObject in Enemy Data to set the stats instead, we are also making our project confusing to handle — if you forget that an enemy’s stats is set by the EnemyScriptableObject in Enemy Data (happens more than you think on a big project!) and set it on the prefab instead, the stats will get overwritten by the EnemyScriptableObject instead. This can sometimes cause bugs that take a long time to find, and can be an enormous waste of time.

5. Adding a new charging enemy movement

Now that we have the new spawn system set up, we’re going to set up special event spawns next.

a. How the charging enemy works

At the top of this article, we’ve already talked about the Flower Wall as one of the event spawns:

Flower Wall
The Flower Wall spawn.

The other event spawns we want are mob spawns, which look like this:

Notice that they don’t move towards the player.

These enemies do not track the player. Instead, when they spawn, they are pointed in the direction of the player at the time of spawn, and do not change their trajectory afterwards. Hence, if the player moves away, the enemies will continue to move towards where the player was.

b. The ChargingEnemyMovement script

Remember how we made the EnemyMovement script contain a bunch of protected and virtual variables and functions? The reason we did that was because I wanted to make the script support subclassing.

For our ChargingEnemyMovement script, we want the enemies to behave mostly the same, except that it does not follow the player. Hence, instead of writing a brand new script, we simply write a new script that inherits from EnemyMovement and override the Move() function instead:

ChargingEnemyMovement.cs

using UnityEngine;

public class ChargingEnemyMovement : EnemyMovement
{

    Vector2 chargeDirection;

    // We calculate the direction where the enemy charges towards first,
    // i.e. where the player is when the enemy spawns.
    protected override void Start()
    {
        base.Start();
        chargeDirection = (player.transform.position - transform.position).normalized;
    }

    // Instead of moving towards the player, we just move towards
    // the direction we are charging towards.
    public override void Move()
    {
        transform.position += (Vector3)chargeDirection * enemy.currentMoveSpeed * Time.deltaTime;
    }
}

c. Testing out ChargingEnemyMovement

To test out this script, you can make a prefab variant of your Bat enemy. In this variant, you will replace the EnemyMovement script with a ChargingEnemyMovement script instead. This will cause the bat to charge towards a direction, instead of tracking the player.

Bat prefab variant
The Out Of Frame Action is set to Despawn, since mob type enemies just keep going straight.

You might also want to increase the move speed of this Mob variant of your bat enemy, so that it feels more threatening.

To create a prefab variant, simply right-click on the prefab, then go to Create > Prefab Variant. Prefab Variants are convenient ways for us to create different variations of a prefab, so that when we change the main prefab, the variant will become affected as well.

d. Adding more movement types

If you like to create more kinds of enemies that move differently, you can create more subclasses of EnemyMovement and override the Move() function. For example, Vampire Survivors has mage-type enemies that hover around the player instead of moving.

Give it a try and share your code in the forums!

6. Adding the EventManager

Now that we have the enemy scripts all sorted out, let’s look at creating an EventManager script. In Vampire Survivors, random events happen every 30 seconds, and these events will spawn either mobs or items in certain configurations. Before we do that, however, let’s create a new scriptable object called EventData.

a. A new EventData scriptable object

This new EventData scriptable object will be similar to the WaveData object. Hence, it will inherit from the SpawnData class we created earlier. However, because it is used by the EventManager we will be creating later (instead of the SpawnManager), we will also introduce a few new properties unique to it.

EventData.cs

using UnityEngine;

public abstract class EventData : SpawnData
{
    [Header("Event Data")]
    [Range(0f, 1f)] public float probability = 1f; // Whether this event occurs.
    [Range(0f, 1f)] public float luckFactor = 1f; // How much luck affects the probability of this event.

    [Tooltip("If a value is specified, this event will only occur after the level runs for this number of seconds.")]
    public float activeAfter = 0;

    public abstract bool Activate(PlayerStats player = null);

    // Checks whether this event is currently active.
    public bool IsActive()
    {
        if (!GameManager.instance) return false;
        if (GameManager.instance.GetElapsedTime() > activeAfter) return true;
        return false;
    }

    // Calculates a random probability of this event happening.
    public bool CheckIfWillHappen(PlayerStats s)
    {
        // Probability of 1 means it always happens.
        if (probability >= 1) return true;

        // Otherwise, get a random number and see if we pass the probability test.
        if(probability / Mathf.Max(1,(s.Stats.luck * luckFactor)) >= Random.Range(0f, 1f))
            return true;

        return false;
    }
}

The EventData class is a ScriptableObject, which means it is meant to be used like WaveData — for creating asset data files in our project. However, note that this class is also abstract, which means that we are still unable to create any EventData scriptable objects yet. To use this, we will need to create more scripts that subclass EventData later on.

What is the purpose of EventData? Primarily, it contains variables that we are able to adjust to control how often we get to skip an event (a higher Luck stat allows us to skip certain events in Vampire Survivors):

  1. It contains a probabilityFactor property, which is the basic probability of the event happening. If set to 1, it will always happen.
  2. It also contains a luckFactor property, which controls how much Luck is able to affect the probability of the event skipping. The higher the Luck, the likelier that the event will be skipped (following the formula in the link above).
  3. Finally, we also have an activeAfter property, which can be set to prevent certain events from happening too early. For example, if you want the Flower Wall event to start spawning only after 5 minutes in-game, you can set activeAfter to 250.

The CheckIfWillHappen() function will be used by the EventManager we are coding later to see if the event will happen, based on probabilityFactor and luckFactor. The IsActive() function will also be used by the EventManager later on, to check if the in-game time is already more than activeAfter.

Finally, we also have an abstract Activate() function. This function will be overrided by our EventData subclasses later, to allow us to create different kinds of spawn patterns.

Note that this script also uses a function called GetElapsedTime() in IsActive(), which currently doesn’t exist from on the GameManager. Hence, for this script to work, we’ll need to also:

b. Updating the GameManager

The update is simple. We’ll just need to create a new GetElapsedTime() function, which returns the stopwatchTime variable in the GameManager. This is because the EventData needs to be able to check how long the game has been running.

GameManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class GameManager : MonoBehaviour
{
    public static GameManager instance;

    // Define the different states of the game
    public enum GameState
    {
        Gameplay,
        Paused,
        GameOver,
        LevelUp
    }

    // Store the current state of the game
    public GameState currentState;

    // Store the previous state of the game before it was paused
    public GameState previousState;

    [Header("Damage Text Settings")]
    public Canvas damageTextCanvas;
    public float textFontSize = 20;
    public TMP_FontAsset textFont;
    public Camera referenceCamera;

    [Header("Screens")]
    public GameObject pauseScreen;
    public GameObject resultsScreen;
    public GameObject levelUpScreen;
    int stackedLevelUps = 0; // If we try to StartLevelUp() multiple times.

    [Header("Results Screen Displays")]
    public Image chosenCharacterImage;
    public TMP_Text chosenCharacterName;
    public TMP_Text levelReachedDisplay;
    public TMP_Text timeSurvivedDisplay;


    [Header("Stopwatch")]
    public float timeLimit; // The time limit in seconds
    float stopwatchTime; // The current time elapsed since the stopwatch started
    public TMP_Text stopwatchDisplay;

    // Reference to the player's game object
    public GameObject playerObject;

    // Getters for parity with older scripts.
    public bool isGameOver { get { return currentState == GameState.Paused; } }
    public bool choosingUpgrade { get { return currentState == GameState.LevelUp; } }

    // Gives us the time since the level has started.
    public float GetElapsedTime() { return stopwatchTime; }

    void Awake()
    {
        //Warning check to see if there is another singleton of this kind already in the game
        if (instance == null)
        {
            instance = this;
        }
        else
        {
            Debug.LogWarning("EXTRA " + this + " DELETED");
            Destroy(gameObject);
        }

        DisableScreens();
    }

    void Update()
    {
        switch (currentState)
        {
            case GameState.Gameplay:
                // Code for the gameplay state
                CheckForPauseAndResume();
                UpdateStopwatch();
                break;
            case GameState.Paused:
                // Code for the paused state
                CheckForPauseAndResume();
                break;
            case GameState.GameOver:
            case GameState.LevelUp:
                break;
            default:
                Debug.LogWarning("STATE DOES NOT EXIST");
                break;
        }
    }

    IEnumerator GenerateFloatingTextCoroutine(string text, Transform target, float duration = 1f, float speed = 50f)
    {
        // Start generating the floating text.
        GameObject textObj = new GameObject("Damage Floating Text");
        RectTransform rect = textObj.AddComponent<RectTransform>();
        TextMeshProUGUI tmPro = textObj.AddComponent<TextMeshProUGUI>();
        tmPro.text = text;
        tmPro.horizontalAlignment = HorizontalAlignmentOptions.Center;
        tmPro.verticalAlignment = VerticalAlignmentOptions.Middle;
        tmPro.fontSize = textFontSize;
        if (textFont) tmPro.font = textFont;
        rect.position = referenceCamera.WorldToScreenPoint(target.position);

        // Makes sure this is destroyed after the duration finishes.
        Destroy(textObj, duration);

        // Parent the generated text object to the canvas.
        textObj.transform.SetParent(instance.damageTextCanvas.transform);
        textObj.transform.SetSiblingIndex(0);

        // Pan the text upwards and fade it away over time.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0;
        float yOffset = 0;
        Vector3 lastKnownPosition = target.position;
        while (t < duration)
        {
            // If the RectTransform is missing for whatever reason, end this loop.
            if (!rect) break;

            // Fade the text to the right alpha value.
            tmPro.color = new Color(tmPro.color.r, tmPro.color.g, tmPro.color.b, 1 - t / duration);

            // Update the enemy's position if it is still around.
            if (target) lastKnownPosition = target.position;

            // Pan the text upwards.
            yOffset += speed * Time.deltaTime;
            rect.position = referenceCamera.WorldToScreenPoint(lastKnownPosition + new Vector3(0, yOffset));

            // Wait for a frame and update the time.
            yield return w;
            t += Time.deltaTime;
        }
    }

    public static void GenerateFloatingText(string text, Transform target, float duration = 1f, float speed = 1f)
    {
        // If the canvas is not set, end the function so we don't
        // generate any floating text.
        if (!instance.damageTextCanvas) return;

        // Find a relevant camera that we can use to convert the world
        // position to a screen position.
        if (!instance.referenceCamera) instance.referenceCamera = Camera.main;

        instance.StartCoroutine(instance.GenerateFloatingTextCoroutine(
            text, target, duration, speed
        ));
    }

    // Define the method to change the state of the game
    public void ChangeState(GameState newState)
    {
        previousState = currentState;
        currentState = newState;
    }

    public void PauseGame()
    {
        if (currentState != GameState.Paused)
        {
            ChangeState(GameState.Paused);
            Time.timeScale = 0f; // Stop the game
            pauseScreen.SetActive(true); // Enable the pause screen
        }
    }

    public void ResumeGame()
    {
        if (currentState == GameState.Paused)
        {
            ChangeState(previousState);
            Time.timeScale = 1f; // Resume the game
            pauseScreen.SetActive(false); //Disable the pause screen
        }
    }

    // Define the method to check for pause and resume input
    void CheckForPauseAndResume()
    {
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            if (currentState == GameState.Paused)
            {
                ResumeGame();
            }
            else
            {
                PauseGame();
            }
        }
    }

    void DisableScreens()
    {
        pauseScreen.SetActive(false);
        resultsScreen.SetActive(false);
        levelUpScreen.SetActive(false);
    }

    public void GameOver()
    {
        timeSurvivedDisplay.text = stopwatchDisplay.text;

        // Set the Game Over variables here.
        ChangeState(GameState.GameOver);
        Time.timeScale = 0f; //Stop the game entirely
        DisplayResults();
    }

    void DisplayResults()
    {
        resultsScreen.SetActive(true);
    }

    public void AssignChosenCharacterUI(CharacterData chosenCharacterData)
    {
        chosenCharacterImage.sprite = chosenCharacterData.Icon;
        chosenCharacterName.text = chosenCharacterData.Name;
    }

    public void AssignLevelReachedUI(int levelReachedData)
    {
        levelReachedDisplay.text = levelReachedData.ToString();
    }



    void UpdateStopwatch()
    {
        stopwatchTime += Time.deltaTime;
        UpdateStopwatchDisplay();

        if (stopwatchTime >= timeLimit)
        {
            playerObject.SendMessage("Kill");
        }
    }

    void UpdateStopwatchDisplay()
    {
        // Calculate the number of minutes and seconds that have elapsed
        int minutes = Mathf.FloorToInt(stopwatchTime / 60);
        int seconds = Mathf.FloorToInt(stopwatchTime % 60);

        // Update the stopwatch text to display the elapsed time
        stopwatchDisplay.text = string.Format("{0:00}:{1:00}", minutes, seconds);
    }

    public void StartLevelUp()
    {
        ChangeState(GameState.LevelUp);

        // If the level up screen is already active, record it.
        if (levelUpScreen.activeSelf) stackedLevelUps++;
        else
        {
            levelUpScreen.SetActive(true);
            Time.timeScale = 0f; //Pause the game for now
            playerObject.SendMessage("RemoveAndApplyUpgrades");
        }
    }

    public void EndLevelUp()
    {
        Time.timeScale = 1f;    //Resume the game
        levelUpScreen.SetActive(false);
        ChangeState(GameState.Gameplay);

        if (stackedLevelUps > 0)
        {
            stackedLevelUps--;
            StartLevelUp();
        }
    }
}

Now that the EventData is set up, we can move on to creating the EventManager.

c. The EventManager script

Here is the EventManager script. It is a pretty straightforward one, containing:

  1. An array of events, containing EventData scriptable objects, that determine what events it is capable of triggering.
  2. A firstTriggerDelay variable, which allows us to control when the first event starts firing.
  3. A triggerInterval variable, which controls how often events fire (after the first event fires).

Most of the logic is in Update(), which is responsible for counting down, and randomly picking an event from the events array whenever the countdown expires. We have a GetRandomEvent() function to help randomly pick an event in Update() as well, since we need to loop through every event and check if it is active before we can pick it.

EventManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EventManager : MonoBehaviour
{

    float currentEventCooldown = 0;

    public EventData[] events;

    [Tooltip("How long to wait before this becomes active.")]
    public float firstTriggerDelay = 180f;

    [Tooltip("How long to wait between each event.")]
    public float triggerInterval = 30f;

    public static EventManager instance;

    [System.Serializable]
    public class Event
    {
        public EventData data;
        public float duration, cooldown = 0;
    }
    List<Event> runningEvents = new List<Event>(); // These are events that have been activated, and are running.

    PlayerStats[] allPlayers;

    // Start is called before the first frame update
    void Start()
    {
        if (instance) Debug.LogWarning("There is more than 1 Spawn Manager in the Scene! Please remove the extras.");
        instance = this;
        currentEventCooldown = firstTriggerDelay > 0 ? firstTriggerDelay : triggerInterval;
        allPlayers = FindObjectsOfType<PlayerStats>();
    }

    // Update is called once per frame
    void Update()
    {
        // Cooldown for adding another event to the slate.
        currentEventCooldown -= Time.deltaTime;
        if (currentEventCooldown <= 0)
        {
            // Get an event and try to execute it.
            EventData e = GetRandomEvent();
            if (e && e.CheckIfWillHappen(allPlayers[Random.Range(0, allPlayers.Length)]))
                runningEvents.Add(new Event
                {
                    data = e,
                    duration = e.duration
                });

            // Set the cooldown for the event.
            currentEventCooldown = triggerInterval;
        }

        // Events that we want to remove.
        List<Event> toRemove = new List<Event>();

        // Cooldown for existing event to see if they should continue running.
        foreach (Event e in runningEvents)
        {
            // Reduce the current duration.
            e.duration -= Time.deltaTime;
            if (e.duration <= 0)
            {
                toRemove.Add(e);
                continue;
            }

            // Reduce the current cooldown.
            e.cooldown -= Time.deltaTime;
            if (e.cooldown <= 0)
            {
                // Pick a random player to sic this mob on,
                // then reset the cooldown.
                e.data.Activate(allPlayers[Random.Range(0, allPlayers.Length)]);
                e.cooldown = e.data.GetSpawnInterval();
            }
        }

        // Remove all the events that have expired.
        foreach (Event e in toRemove) runningEvents.Remove(e);
    }

    public EventData GetRandomEvent()
    {
        // If no events are assigned, don't return anything.
        if (events.Length <= 0) return null;

        // Get a list of all possible events.
        List<EventData> possibleEvents = new List<EventData>(events);

        // Randomly pick an event and check if it can be used.
        // Keep doing this until we find a suitable event.
        EventData result = possibleEvents[Random.Range(0, possibleEvents.Count)];
        while (!result.IsActive())
        {
            possibleEvents.Remove(result);
            if (possibleEvents.Count > 0)
                result = events[Random.Range(0, possibleEvents.Count)];
            else
                return null;
        }
        return result;
    }
}

Here is a screenshot of how the EventManager component looks like:

The Event Manager component
It’s a pretty simple component, all things considered.

Now that we’ve got the EventManager set up, we will need to create the events that the component will spawn!

7. Creating the Mob Event

The Mob Event is a pretty straightforward one. It is when you get a swarm of enemies rushing across the map like this:

This is a mob event.

a. Creating the MobEventData

To create mobs like this, we will need to create a MobEventData which subclasses the EventData script. All of the EventData scripts that we create will override the Activate() function, which is responsible for determining how the GameObjects spawned by the event will be configured.

MobEventData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "Mob Event Data", menuName = "2D Top-down Rogue-like/Event Data/Mob")]
public class MobEventData : EventData
{
    [Header("Mob Data")]
    [Range(0f, 360f)] public float possibleAngles = 360f;
    [Min(0)] public float spawnRadius = 2f, spawnDistance = 20f;

    public override bool Activate(PlayerStats player = null)
    {
        // Only activate this if the player is present.
        if(player)
        {
            // Otherwise, we spawn a mob outside of the screen and move it towards the player.
            float randomAngle = Random.Range(0, possibleAngles) * Mathf.Deg2Rad;
            foreach (GameObject o in GetSpawns())
            {
                Instantiate(o, player.transform.position + new Vector3(
                    (spawnDistance + Random.Range(-spawnRadius, spawnRadius)) * Mathf.Cos(randomAngle),
                    (spawnDistance + Random.Range(-spawnRadius, spawnRadius)) * Mathf.Sin(randomAngle)
                ), Quaternion.identity);
            }
        }

        return false;
    }
}

b. Explaining the MobEventData

Here is how the MobEventData scriptable object looks like when you try creating a copy of it:

Mob Event Data
It is pretty similar to WaveData scriptable objects… with a few extra properties.

The first set of properties belong to SpawnData, which you can find on WaveData as well. Refer to the part above to get a recap on its properties. With a Duration of 12, and a Spawn Interval of 2 to 3, this event will spawn anywhere between 4 to 6 groups of enemies. If you want this event to only fire once, make sure the Duration is less than the Spawn Interval values.

Note: Make sure you put at least 1 enemy prefab under Possible Spawn Prefabs as well!

The properties under Event Data belong to the EventData class, and control the probability of this event happening. I set the Probability to 1 so that the event always fires.

The properties under Mob Data are unique to the MobEventData. It controls:

  1. The possibleAngles that the mob can come from.
  2. How large a spawnRadius the mobs can spawn in. If you set this to 0, they will all spawn and stack at the same spot, so give this a reasonable value.
  3. How far away from the player it spawns (spawnDistance).

Once you’ve set up a new MobEventData asset, assign it to the EventManager and test it out!

8. Creating the infamous Flower Wall

Now that the mobs are created, let’s create the infamous Flower Wall!

Flower Wall
Finally…

a. Creating the RingEventData

The Ring Event Data is a script that will create a ring of GameObjects around the player character. This need not necessarily spawn enemies — it can spawn any kind of GameObject.

RingEventData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "Ring Event Data", menuName = "2D Top-down Rogue-like/Event Data/Ring")]
public class RingEventData : EventData
{
    [Header("Mob Data")]
    public ParticleSystem spawnEffectPrefab;
    public Vector2 scale = new Vector2(1, 1);
    [Min(0)] public float spawnRadius = 10f, lifespan = 15f;

    public override bool Activate(PlayerStats player = null)
    {
        // Only activate this if the player is present.
        if (player)
        {
            GameObject[] spawns = GetSpawns();
            float angleOffset = 2 * Mathf.PI / Mathf.Max(1, spawns.Length);
            float currentAngle = 0;
            foreach(GameObject g in spawns)
            {
                // Calculate the spawn position.
                Vector3 spawnPosition = player.transform.position + new Vector3(
                    spawnRadius * Mathf.Cos(currentAngle) * scale.x,
                    spawnRadius * Mathf.Sin(currentAngle) * scale.y
                );

                // If a particle effect is assigned, play it on the position.
                if(spawnEffectPrefab)
                    Instantiate(spawnEffectPrefab, spawnPosition, Quaternion.identity);

                // Then spawn the enemy.
                GameObject s = Instantiate(g, spawnPosition, Quaternion.identity);

                // If there is a lifespan on the mob, set them to be destroyed.
                if (lifespan > 0) Destroy(s, lifespan);

                currentAngle += angleOffset;
            }
        }

        return false;
    }
}

b. Explaining the RingEventData

Like before, here’s how the component looks like:

Ring Event Data
Looks familiar, doesn’t it?

Pretty similar to the MobEventData, except for the Mob Data portion, which contains a different set of properties.

  1. The Spawn Radius property is the most important by far, because it determines how large the ring will be.
  2. The next most important property is Scale, which allows you to create oval-shaped rings (instead of perfectly circular ones). For example, with a Scale of (2, 1), my rings appear like this instead:
Oval ring mob example
The Scale property allows you to deform the circle.
  1. The Lifespan property allows you to set a time limit to how long the mob lasts.
  2. Finally, the Spawn Effect Prefab allows you to assign a particle effect that will play on the spot where the enemy will spawn. This can be left empty if you don’t want any visual effect to play, but it can help make the spawn look less sudden.

c. The Flower sprite sheet

If you can’t find any suitable sprites for your flower enemy, here is the sprite sheet I used.

Cute Monsters Pack
Original sprites from Master484.

When creating the enemy prefabs for your new enemies, note that you can use Animator Override Controllers as well, since all of the enemies share the same animation states.

9. Additional enemy sprite sheets

If you need sprites for more kinds of enemies, you can also use these:

ZombieSkeleton
Asset created by Reemax.

10. Conclusion

And that about wraps up this part! As usual, if you are a Silver Patron and above, you can also download the project files.

In the next part, we will be expanding upon the list of stats that enemies have.

Leave a Reply

Your email address will not be published. Required fields are marked *

Note: You can use Markdown to format your comments.

For security, use of Google's reCAPTCHA service is required which is subject to the Google Privacy Policy and Terms of Use.

I agree to these terms.

This site uses Akismet to reduce spam. Learn how your comment data is processed.