Help with an enemy counter

I am using Unity 2021.3.27f1. I am creating a game for personal use that will be played on an old, refurbished PC (possibly from 2017) with an Intel Core i7 processor. I am a hobby programmer with very little coding experience. The Unity junior programmer pathway is the extent to what I know about C# and the Unity Editor.

The game is a very basic shmup that is the final project of the pathway and is geared toward demonstrating a basic understanding of the 4 pillars of OOP as taught by the pathways. The project is basically ready for polishing and submission except for a single feature that is driving me crazy.

The feature in question is an enemy counter that determines when a wave of enemies is cleared, and a new wave is spawned. The number of enemies in the scene is equal to the current wave number and a tank mob can summon an infantry mob at random intervals throughout the wave. Wave number is limited to the amount of available preset positions for every mob type. As waves progress, these positions eventually all fill up until no more enemies can be assigned to a post and the game is won.

With small numbers of enemies (20 seems to be the magic number), the counter sems to work perfectly, and all the numbers match up. For every mob spawned the counter is incremented, for every mob destroyed the counter is decremented, and when no more enemies are in the scene, a new wave is spawned. However, at about 24 enemies and upwards, I believe somewhere along the line a mob is not being counted when it is created or is being counted twice when it is being destroyed. This bug causes the game to spawn a new wave of enemies with the remaining enemies from the previous wave still in the scene and unaccounted for. If they are killed, they throw the count of even more.

During test plays I have:

  • checked my collision matrix to make sure all the layers were interacting properly

  • assigned continuous dynamic collision detection to the infantry and player class in the inspector to use Unity’s built in methods for predicting collisions(I’m pretty sure that’s what this does but could be wrong, I may have misunderstood the manual)

  • tried to use debug statements to see each individual type of collision that would cause the counter to decrement and when the spawn events that would increment the counter occurred. This did not lead to any causes of the bug as the math always balanced out, but there were still obvious stragglers left from the previous scene.

  • tested gameplay over and over, using different amounts of preset positions, starting at 54, working my way down to 20

  • During some of the plays even the runs with higher numbers of mobs had a working counter, meaning sometimes the wave would start at the correct moment and sometimes it would be off by 1 enemy or more.

I believe that spawning an infantry during the wave and its interactions with the player are what is throwing the count off. This is evidenced by test plays in which I utilize the players shield to put the infantry in a defensive state where they flee to a defensive position. This causes their numbers to build up overtime until they force their fleeing comrades into my shield for a kill.

All mobs are children of a parent class that handles their destruction collisions and logic that activates cooldown states for actions and buffs. The children classes all contain code for unique skills and logic pertaining to movement and triggers that contribute to their interactions and behaviors in the scene.

All mobs use a rigid body, even if motionless, because it was easier for me to handle the collisions this way and most posts I have read say that you don’t need to worry about the cost of rigid bodies until they reach the thousands.

The following code is where I believe the problem comes from.

The Mob parent class, specifically the collisions that cause the destruction of an object:

// mob takes damage equal to player strength, destroys mob if less than 1 health
protected virtual void TakeDamage()
{
    mobHealth -= playerController.playerStr;

    if (mobHealth <= 0)
    {
        Destroy(gameObject);
        SpawnManager.DecerimentEnemyCount();

        // debug
        SpawnManager.IncrementBulletKill();
        Debug.Log("BULLET KILL AT: " + Time.frameCount + " " + gameObject.tag + " BK: " + SpawnManager.bulletDeath + " EC: " + SpawnManager.enemyCount);
    }
}

protected virtual void OnCollisionEnter(Collision collision)
{
    // if bullet hits mob, set bullet to false to repool and reet duration bool, finally damage the monster
    if (collision.gameObject.CompareTag("bullet-player"))
    {
        collision.gameObject.GetComponent<BulletPlayerController>().fired = false;
        collision.gameObject.SetActive(false);
        TakeDamage();
    }

    //  deal damage to playerdestroy infantry instance on contact, unawarded
    if (collision.gameObject.CompareTag("Player") && !playerController.isShielding)
    {
        DealDamage();
        Destroy(gameObject);
        SpawnManager.DecerimentEnemyCount();

        // debug
        SpawnManager.IncrementSuicide();
        Debug.Log("SUICIDE AT: " + Time.frameCount + " S: " + SpawnManager.suicide + " EC: " + SpawnManager.enemyCount + "PH: " + playerController.playerHealth);
    }
  
    // destroy mob on contact with player if it is shielding
    if (collision.gameObject.CompareTag("Player") && playerController.isShielding)
    {
        Destroy(gameObject);
        SpawnManager.DecerimentEnemyCount();

        // debug
        SpawnManager.IncrementShieldKill();
        Debug.Log("SHIELD KILL AT: " + Time.frameCount + " SK:  " + SpawnManager.shieldKill + " EC: " + SpawnManager.enemyCount);
    }
}

The Tank Mob, with special attention to SummonInfantry():

public class TankMobController : MobController
{
    // shield reference
    [SerializeField] GameObject shield;

    // allow access to spawn manager
    private SpawnManager spawnManager;

    // reference to summonable mob
    [SerializeField] GameObject mobSummon;

    [SerializeField] int spawnCooldownMin;
    [SerializeField] int spawnCooldownMax;

    // spawner state
    [SerializeField] bool isSpawning = false;

    // spawner offset
    [SerializeField] float spawnOffset = 1.0f;

    protected override void Start()
    {
        base.Start();

        StartCoroutine(ToggleSpawnState());
    }

    protected override void Update()
    {
        base.Update();

        ToggleShieldActive();
        SummonInfantry();
    }

    // turn shield object on or off depending on state
    void ToggleShieldActive()
    {
        if (actionEnabled && !SpawnManager.Instance.gameOver)
        {
            shield.SetActive(true);
        }
        else
        {
            shield.SetActive(false);
        }
    }

    // raise health, limited to MaxHealth
    public virtual void RaiseHealth()
    {

        if (mobHealth == 0)
        {
            // if you see this the tank is throing off your count
            Debug.Log("I SHOULD BE DEAD");
        }

        mobHealth += 1;

        if (mobHealth > MaxHealth)
        {
            mobHealth = MaxHealth;
        }
    }

    // toggle the state of the spawner
    IEnumerator ToggleSpawnState()
    {
        while (true && !SpawnManager.Instance.gameOver)
        {
            yield return new WaitForSecondsRealtime(Random.Range(spawnCooldownMin, spawnCooldownMax));
            isSpawning = !isSpawning;
        }
    }

    // summon an infantry unit above the tank if isSpawning state is true and reset for another summon
    void SummonInfantry()
    {
        if (isSpawning && !SpawnManager.Instance.gameOver)
        {
            Instantiate(mobSummon, new Vector3(transform.position.x, transform.position.y + spawnOffset, transform.position.z), transform.rotation);
            isSpawning = false;
            SpawnManager.IncrementEnemyCount();

            // debug
            SpawnManager.IncrementInfantrySummoned();
            Debug.Log("spawned at: " + Time.frameCount + " IC: " + SpawnManager.summoned + "EC: " + SpawnManager.enemyCount);
        }
    }
}

The Infantry mob, has an attached empty game object with a collider set as a trigger for sensing the player:

public class InfantryMobController : MobController
{
    // player reference
    private PlayerController player;
    // allow access to rigid body
    private Rigidbody infantryRb;
    // attack state
    [SerializeField] bool offensive = true;
    // post references
    [SerializeField] Transform[] posts;
    private Transform post;
 
    // attributes unique to an infantry mob
    [SerializeField] float m_Speed;
    public float Speed
    {
        get { return m_Speed; }
        set { m_Speed = value; }
    }
    // boundary references
    private int boundaryRange = 9;
    protected override void Awake()
    {
        base.Awake();     
     
        // allow access to rigid body
        infantryRb = GetComponent<Rigidbody>();
        post = AssignPost();
        player = GameObject.Find("player").GetComponent<PlayerController>();
    }
    // Update is called once per frame
    protected override void Update()
    {
        base.Update();
        MoveWithAI();
    }
    // infantry movement with simple AI determining offensive or defensive movement
    void MoveWithAI()
    {
        if (offensive && !SpawnManager.Instance.gameOver)
        {
            Movement.MovetTo(player.playerRb, infantryRb, Speed);
        }
        else
        {
            Movement.MoveTo(post, infantryRb, Speed);
            StartCoroutine(Cooldown());
        }
        Movement.KeepInBounds(infantryRb, boundaryRange);
    }
    // assign a random defensive post for unit to flee to when defensive
    Transform AssignPost()
    {
        int postNumber = Random.Range(0, posts.Length);
     
        post = posts[postNumber];
        return post;
    }
    // action cooldown timer
    private IEnumerator Cooldown()
    {
        yield return new WaitForSecondsRealtime(cooldownMax);
        offensive = true;
    }
    // raise speed
    public void RaiseSpeed()
    {
        Speed += 0.1f;
    }
    private void OnTriggerStay(Collider other)
    {
        // change to defense mode when in range of the shielded player
        if (other.gameObject.CompareTag("Player") && player.isShielding)
        {
            offensive = false;
        }
    }
}

I can try to answer any questions as best I can.

If anyone needs to see more code just ask and I can post whatever you might think you need. The SpawnManager code that increments or decrements is just variable++; or variable--;, if that helps.

Sorry for the long post, but I thought supplying as much information as I could, would be helpful in finding a solution. I am stumped here and would really appreciate any help or advice offered.

Thank you in advance, your time and consideration are appreciated.

A few possibilities here:

  • You didn’t include all the code for MobController, and it’s unclear how and where SpawnManager.IncrementEnemyCount(); is called when mobs are spawned in a way other than the tank spawning infantry. It’s possible you’re messing something up there, perhaps not incrementing the enemy count in some cases.
  • Your OnCollisionEnter code doesn’t properly handle the case when multiple collisions happen in the same physics frame. For example if a single enemy is hit by two bullets in a single physics frame, you will get two calls to SpawnManager.DecerimentEnemyCount(); for one enemy. Likewise, if there are two collisions with the player in the same physics frame, or one collision with the player and one with a bullet, you will also get more than one call to SpawnManager.DecerimentEnemyCount(); for that single enemy. You need to make sure each enemy death is only counted once.

Here is the entire SpawnManager. This is where the where the initial and subsequent waves are spawned. I am pretty sure this is not the culprit, but please have a look to be safe.

public class SpawnManager : MonoBehaviour
{
    public static SpawnManager Instance { get; private set; }

    // supporter references
    [SerializeField] GameObject support;
    [SerializeField] List<Transform> emptySupportSpawnPoints = new List<Transform>();
    private List<Transform> fullSupportSpawnPoints = new List<Transform>();
    private bool supporterFull = false;

    // tank references
    [SerializeField] GameObject tank;
    [SerializeField] List<Transform> emptyTankSpawnPoints = new List<Transform>();
    private List<Transform> fullTankSpawnPoints = new List<Transform>();
    private bool tankFull = false;

    // infantry references
    [SerializeField] GameObject infantry;
    [SerializeField] List<Transform> emptyInfantrySpawnPoints = new List<Transform>();
    private List<Transform> fullInfantrySpawnPoints = new List<Transform>();
    private bool infantryFull = false;

    // ranged references
    [SerializeField] GameObject ranged;
    [SerializeField] List<Transform> emptyRangedSpawnPoints = new List<Transform>();
    private List<Transform> fullRangedSpawnPoints = new List<Transform>();
    private bool rangedFull = false;

    [SerializeField] private int waveNumber = 1;
    public static int enemyCount;

    [SerializeField] TextMeshProUGUI enemies;

    public bool gameOver = false;

    // debug variables
    public static int shieldKill = 0;
    public static int suicide = 0;
    public static int bulletDeath = 0;
    public static int summoned = 0;


    // singleton setup
    private void Awake()
    {
        if (Instance != null)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
    }
    private void Start()
    {
        SpawnWave();
    }
    private void Update()
    {
        SpawnWave();
        //if (Input.GetButtonDown("Fire3"))
        //{
        //    SpawnMob(support, 0, 0, -5, gameObject.transform.rotation);
        //}
    }

    // parameters corrospond to to the references for each mob in the declarations at the top of the page
    void SpawnMob(GameObject mobType, List<Transform> emptySpawnPoints, List<Transform> fullSpawnPoints, ref bool pointsFull)
    {
        if (emptySpawnPoints.Count > 0)
        {
            int randomIndex = Random.Range(0, emptySpawnPoints.Count);
            var selectedPoint = emptySpawnPoints[randomIndex];

            if (selectedPoint != null)
            {
                Instantiate(mobType, selectedPoint);
                fullSpawnPoints.Add(emptySpawnPoints[randomIndex]);
                emptySpawnPoints.RemoveAt(randomIndex);
            }

            if (emptySpawnPoints.Count <= 0)
            {
                pointsFull = true;
            }

            IncrementEnemyCount();
        }
    }

    // abstracted resetter for any mob type
    void ResetSpawnPoints(List<Transform> emptySpawnPoints, List<Transform> fullSpawnPoints, ref bool pointsFull)
    {
        foreach (Transform t in fullSpawnPoints)
        {
            emptySpawnPoints.Add(t);
        }
        fullSpawnPoints.Clear();

        pointsFull = false;
    }

    // ensure a random number for type selection every call
    int MobChooser()
    {
        return Random.Range(1, 5);
    }

    // sets winninng conditions and proceeds if they aren't met
    // spawn a random mob type only if there are empty spaces left
    // works recursively until a suitable choice is made
    void ChooseMobType()
    {
        if (infantryFull && rangedFull && supporterFull && tankFull && !gameOver)
        {
            gameOver = true;
            Time.timeScale = 0;

            Debug.Log("VICTORY");
            Debug.Log("Shield kill: " + shieldKill);
            Debug.Log("Bullet kill: " + bulletDeath);
            Debug.Log("Suicide: " + suicide);
            Debug.Log("Infantry summons: " + summoned);
        }
        else
        {
            int chooseMobType = MobChooser();

            // spawn an infantry
            if (chooseMobType == 1)
            {
                if (infantryFull)
                {
                    ChooseMobType();
                }
                else
                {
                    SpawnMob(infantry, emptyInfantrySpawnPoints, fullInfantrySpawnPoints, ref infantryFull);
                }
            }

            // spawn a supporter
            if (chooseMobType == 2)
            {
                if (supporterFull)
                {
                    ChooseMobType();
                }
                else
                {
                    SpawnMob(support, emptySupportSpawnPoints, fullSupportSpawnPoints, ref supporterFull);
                }
            }


            // spaw a tank
            if (chooseMobType == 3)
            {
                if (tankFull)
                {
                    ChooseMobType();
                }
                else
                {
                    SpawnMob(tank, emptyTankSpawnPoints, fullTankSpawnPoints, ref tankFull);
                }
            }

            // spawn a ranged
            if (chooseMobType == 4)
            {
                if (rangedFull)
                {
                    ChooseMobType();
                }
                else
                {
                    SpawnMob(ranged, emptyRangedSpawnPoints, fullRangedSpawnPoints, ref rangedFull);
                }
            }
        }
    }

    // spawn a wave of mobs
    void SpawnWave()
    {
        if (enemyCount == 0)
        {
            ResetSpawnPoints(emptyInfantrySpawnPoints, fullInfantrySpawnPoints, ref infantryFull);

            ResetSpawnPoints(emptyTankSpawnPoints, fullTankSpawnPoints, ref tankFull);

            ResetSpawnPoints(emptyRangedSpawnPoints, fullRangedSpawnPoints, ref rangedFull);

            ResetSpawnPoints(emptySupportSpawnPoints, fullSupportSpawnPoints, ref supporterFull);;

            for (int i = 0; i < waveNumber; i++)
            {
                ChooseMobType();
            }

            waveNumber++;
        }
    }

    public static void DecerimentEnemyCount()
    {
        enemyCount--;
        Instance.enemies.text = enemyCount.ToString();
    }

    public static void IncrementEnemyCount()
    {
        enemyCount++;
        Instance.enemies.text = enemyCount.ToString();
    }

    // debug methods

    public static void IncrementShieldKill()
    {
        shieldKill++;
    }

    public static void IncrementSuicide()
    {
        suicide++;
    }

    public static void IncrementBulletKill()
    {
        bulletDeath++;
    }

    public static void IncrementInfantrySummoned()
    {
        summoned++;
    }
}

Her is the entire MobController.

public class MobController : MonoBehaviour
{
    // inspector reference
    public PlayerController playerController;

    // mob attributes

    // max health protected with backing field for easy initialization
    [SerializeField] int m_MaxHealth;
    public int MaxHealth
    {
        get { return m_MaxHealth; }
        set { m_MaxHealth = value; }
    }

    // mob health place holder
    public int mobHealth;
   
    // damage protected with a backing field for easy initialization
    [SerializeField] int m_MobDamage;
    public int MobDamage
    {
        get { return m_MobDamage; }
        set { m_MobDamage = value; }
    }

    // range for cooldown
    public int cooldownMin;
    public int cooldownMax;

    // support state
    public bool supportCooldownEnabled = false;
    public bool isSupported = false;

    // action state
    protected bool actionEnabled = false;

    protected virtual void Awake()
    {
        // allow access to player
        playerController = GameObject.Find("player").GetComponent<PlayerController>();
    }
    protected virtual void Start()
    {
        mobHealth = MaxHealth;
        StartCoroutine(ActionInterval());
    }

    protected virtual void Update()
    {
        SupportEnabler();
    }

    // enables mob to recieve support buffs once per trigger and starts a cooldown before allowing another buff to be recieved
    protected virtual void SupportEnabler()
    {
        if (isSupported && !supportCooldownEnabled && !SpawnManager.Instance.gameOver)
        {
            supportCooldownEnabled = true;
            StartCoroutine(SupporterCooldown());
        }
    }

    // action state toggler
    protected virtual IEnumerator ActionInterval()
    {
        while (true && !SpawnManager.Instance.gameOver)
        {
            yield return new WaitForSeconds(ActionCooldown());
            actionEnabled = !actionEnabled;
        }
    }

    protected virtual int ActionCooldown()
    {
        return Random.Range(cooldownMin, cooldownMax);
    }

    // deal damage to player
    public virtual void DealDamage()
    {
        playerController.playerHealth -= MobDamage;
    }


    // state toggler for recieving support
    public virtual IEnumerator SupporterCooldown()
    {
            yield return new WaitForSecondsRealtime(cooldownMax);
            supportCooldownEnabled = false;
            isSupported = false;
    }


    // mob takes damage equal to player strength, destroys mob if less than 1 health
    protected virtual void TakeDamage()
    {
        mobHealth -= playerController.playerStr;

        if (mobHealth <= 0)
        {
            Destroy(gameObject);
            SpawnManager.DecerimentEnemyCount();

            // debug
            SpawnManager.IncrementBulletKill();
            Debug.Log("BULLET KILL AT: " + Time.frameCount + " " + gameObject.tag + " BK: " + SpawnManager.bulletDeath + " EC: " + SpawnManager.enemyCount);
        }
    }

    protected virtual void OnCollisionEnter(Collision collision)
    {
        // if bullet hits mob, set bullet to false to repool and reet duration bool, finally damage the monster
        if (collision.gameObject.CompareTag("bullet-player"))
        {
            collision.gameObject.GetComponent<BulletPlayerController>().fired = false;
            collision.gameObject.SetActive(false);
            TakeDamage();
        }

        //  deal damage to playerdestroy infantry instance on contact, unawarded
        if (collision.gameObject.CompareTag("Player") && !playerController.isShielding)
        {
            DealDamage();
            Destroy(gameObject);
            SpawnManager.DecerimentEnemyCount();

            // debug
            SpawnManager.IncrementSuicide();
            Debug.Log("SUICIDE AT: " + Time.frameCount + " S: " + SpawnManager.suicide + " EC: " + SpawnManager.enemyCount + "PH: " + playerController.playerHealth);
        }
       
        // destroy mob on contact with player if it is shielding
        if (collision.gameObject.CompareTag("Player") && playerController.isShielding)
        {
            Destroy(gameObject);
            SpawnManager.DecerimentEnemyCount();

            // debug
            SpawnManager.IncrementShieldKill();
            Debug.Log("SHIELD KILL AT: " + Time.frameCount + " SK:  " + SpawnManager.shieldKill + " EC: " + SpawnManager.enemyCount);
        }
    }
       
}

I think you are right about the multiple collision scenario. How could I alter the collision logic to account for multiple hits? I was under the impression that the continuous dynamic collision detection would do this because it uses a sweep based detection system to predict a collision. Or maybe this is why it is detecting multiple strikes? Miscounts have happened with bullet strikes but it is hard to duplicate. It mainly happens when I activate the shield too much and infantry units build up. During a few play tests I spawned about 100 or so infantry by using OnButtonDown() and this is where I was really able to duplicate the miscounts.

I would still need to be able to take multiple hits however, just not from the same mob. To clarify, if three mobs hit at the same time, three should die one time each. If the same mob hits 3 times, it should only die once. Maybe a bool named iAmDead that is made true for the first strike detected and a condition specifying if (!iAmDead) then count the hit?

The tutorials didn’t really cover this type of situation. I will research other solutions to this while I wait for more feedback. Thanks a lot for the help. Solving this would be great. I have been trying for a couple days to figure it out on my own.

Yeah, the bool did it. Once I knew what too search, I found a couple of different ways to do it. The other way was using a coroutine that uses WaitForFixedUpdate. Its solved now but I am going to play around with the coroutine as well to see which I like more and/or what will also work when dealing damage that is not lethal. I guess it would be okay to leave the bug in that case, it might work as a simple built in critical hit system for the lazy. But I’ll probably just fix it.

Thanks again for pointing me in the right direction. Have a good one.