Unity Enemy Wave Spawner with ScriptableObjects

Illustration of enemies emerging from a spawn portal

Wave-based games (tower defence, arena shooters, survival modes) all need the same core system: spawn groups of enemies, wait until they’re cleared or a timer runs out, then send a harder wave. Hard-coding waves in a script quickly becomes painful to balance. This guide builds a Unity enemy wave spawner driven by ScriptableObject data, so you can design waves in the Inspector, with spawn points, NavMesh-safe positions, object pooling, and reliable tracking of how many enemies are still alive.

Designing the System

A wave is a list of groups. Each group says: spawn this enemy, this many times, this far apart. Groups can run one after another, which is enough for most games. The spawner reads waves in order, spawns their groups, waits for a clear condition, pauses briefly, and moves on.

Keeping waves as data means designers can tweak balance without touching code, and you can make several wave sets (easy, normal, a boss rush) from the same spawner.

Wave Data With ScriptableObjects

using System;
using UnityEngine;

[CreateAssetMenu(menuName = "Waves/Wave")]
public class WaveData : ScriptableObject
{
    public SpawnGroup[] groups;
    [Tooltip("Seconds to wait after this wave is cleared before the next starts.")]
    public float breakAfter = 5f;
    [Tooltip("0 = wait until every enemy is dead. Otherwise move on after this many seconds.")]
    public float timeLimit = 0f;
}

[Serializable]
public class SpawnGroup
{
    public Enemy prefab;
    [Min(1)] public int count = 5;
    [Min(0)] public float interval = 0.5f;   // seconds between spawns in this group
    [Min(0)] public float delayBefore = 0f;
}

Create a few waves with Create > Waves > Wave and fill in groups. Referencing the prefab by its Enemy component type means Instantiate returns the component directly and prevents dropping in the wrong prefab. Our ScriptableObjects guide explains why these assets suit configuration data, and our SerializeField and Inspector attributes guide covers the Min and Tooltip attributes used here.

The Wave Spawner

using System;
using System.Collections;
using UnityEngine;

public class WaveSpawner : MonoBehaviour
{
    [SerializeField] WaveData[] waves;
    [SerializeField] Transform[] spawnPoints;
    [SerializeField] float startDelay = 3f;

    public int CurrentWave { get; private set; }   // 1-based for display
    public int Alive { get; private set; }

    public event Action<int> WaveStarted;
    public event Action<int> WaveCleared;
    public event Action AllWavesComplete;

    IEnumerator Start()
    {
        yield return new WaitForSeconds(startDelay);

        for (int w = 0; w < waves.Length; w++)
        {
            CurrentWave = w + 1;
            WaveStarted?.Invoke(CurrentWave);
            yield return SpawnWave(waves[w]);

            float limit = waves[w].timeLimit;
            float elapsed = 0f;
            while (Alive > 0 && (limit <= 0f || elapsed < limit))
            {
                elapsed += Time.deltaTime;
                yield return null;
            }

            WaveCleared?.Invoke(CurrentWave);
            yield return new WaitForSeconds(waves[w].breakAfter);
        }
        AllWavesComplete?.Invoke();
    }

    IEnumerator SpawnWave(WaveData wave)
    {
        foreach (var group in wave.groups)
        {
            if (group.delayBefore > 0f) yield return new WaitForSeconds(group.delayBefore);
            for (int i = 0; i < group.count; i++)
            {
                Spawn(group.prefab);
                if (group.interval > 0f) yield return new WaitForSeconds(group.interval);
            }
        }
    }

    void Spawn(Enemy prefab)
    {
        Transform point = spawnPoints[UnityEngine.Random.Range(0, spawnPoints.Length)];
        Enemy e = Instantiate(prefab, point.position, point.rotation);
        Alive++;
        e.Died += OnEnemyDied;
    }

    void OnEnemyDied(Enemy e)
    {
        e.Died -= OnEnemyDied;
        Alive--;
    }
}

Using IEnumerator Start as a coroutine keeps the whole wave flow readable, top to bottom. Because the waits use scaled time, the spawner respects the pause menu automatically (see our pause menu guide).

Tracking Enemies Alive

The most common wave bug: the next wave never starts because the alive count never reaches zero. Track enemies with an event the enemy fires exactly once when it dies, rather than searching the scene with FindObjectsByType every frame:

public class Enemy : MonoBehaviour
{
    public event Action<Enemy> Died;
    bool dead;

    public void Kill()
    {
        if (dead) return;     // guard: never fire twice
        dead = true;
        Died?.Invoke(this);
        Destroy(gameObject);
    }
}

Make sure every way an enemy can leave the game calls Kill (or fires Died): health reaching zero, falling out of the world, reaching the player’s base in tower defence, despawning. A forgotten exit path is exactly how waves get stuck at “1 enemy remaining”. A kill-plane trigger under the level that calls Kill on anything that falls is a cheap safety net.

Spawn Points and Safe Positions

Place empty GameObjects around the arena as spawn points and assign them to the array. Some tips:

  • Avoid spawning in view of the player: choose points the camera can’t see, or behind the player, using a dot product with the camera’s forward direction.
  • Avoid spawning on top of each other: add a small random offset (Random.insideUnitCircle * 2f, flattened onto XZ).
  • Snap to the NavMesh so agents don’t spawn off-mesh and freeze:
Vector3 SafePosition(Vector3 desired)
{
    return UnityEngine.AI.NavMesh.SamplePosition(desired, out var hit, 3f, UnityEngine.AI.NavMesh.AllAreas)
        ? hit.position : desired;
}

Agents that spawn off the NavMesh log “SetDestination can only be called on an active agent that has been placed on a NavMesh”. Our Unity 6 NavMesh tutorial covers baking and agent setup.

Pooling Spawned Enemies

Instantiating and destroying dozens of enemies per wave causes garbage and hitches, especially on mobile. Swap to ObjectPool<Enemy>: get from the pool in Spawn, and release back to the pool instead of destroying in Kill. Remember to reset health and state in the pool’s actionOnGet, and reset the dead flag, or recycled enemies die instantly. Full pattern in our object pooling guide.

Endless Waves and Difficulty Scaling

After your designed waves run out, keep going by generating waves from a formula:

WaveData GenerateWave(int waveNumber)
{
    var wave = ScriptableObject.CreateInstance<WaveData>();
    int count = Mathf.RoundToInt(5 + waveNumber * 1.5f);
    wave.groups = new[]
    {
        new SpawnGroup { prefab = basicEnemy, count = count, interval = Mathf.Max(0.15f, 0.8f - waveNumber * 0.03f) },
        new SpawnGroup { prefab = waveNumber % 5 == 0 ? bossEnemy : fastEnemy, count = waveNumber % 5 == 0 ? 1 : waveNumber / 3 }
    };
    wave.breakAfter = 4f;
    return wave;
}

Scale enemy count and spawn rate gradually, and add stronger types every few waves. Multiplying health every wave tends to feel spongy. Mixing enemy types keeps late waves interesting. Destroy generated ScriptableObject instances when done, since created assets aren’t garbage collected automatically.

👾 Need enemies to fill those waves?

Character packs, animation sets and AI tools from the Asset Store let you prototype a full wave game in a weekend. We’ve rounded up the best ones for indies.

See our Asset Store picks →

Wave UI and Events

public class WaveUI : MonoBehaviour
{
    [SerializeField] WaveSpawner spawner;
    [SerializeField] TMPro.TMP_Text waveText, aliveText;

    void OnEnable()  { spawner.WaveStarted += ShowWave; spawner.AllWavesComplete += ShowVictory; }
    void OnDisable() { spawner.WaveStarted -= ShowWave; spawner.AllWavesComplete -= ShowVictory; }

    void ShowWave(int n) => waveText.SetText("Wave {0}", n);
    void ShowVictory() => waveText.text = "Victory!";
    void Update() => aliveText.SetText("Enemies: {0}", spawner.Alive);
}

The spawner doesn’t know the UI exists: it just raises events. That same pattern lets audio play a horn when a wave starts, or a save system record the best wave reached.

Troubleshooting

The next wave never starts

The alive count never reaches zero. An enemy left the game without firing Died (fell through the floor, despawned, reached the goal), or Died fired twice on another enemy and the count went wrong. Guard against double firing and cover every exit path.

Alive count goes negative

Died fires more than once per enemy, or pooled enemies are released twice. Add the dead guard and reset it when reusing.

Enemies spawn and freeze

They spawned off the NavMesh. Use NavMesh.SamplePosition, and check spawn points are over baked areas.

All enemies spawn at once

The interval is 0, or the yield inside the loop is missing.

Waves run while the game is paused

You used WaitForSecondsRealtime or unscaled time. Use scaled waits for gameplay timing.

FAQ

How do I make a wave spawner in Unity?

Store waves as ScriptableObject data, then use a coroutine that spawns each wave’s groups, waits until all enemies are dead or a timer ends, and moves to the next wave.

How do I know when all enemies in a wave are dead?

Count spawned enemies and decrement the count when each fires a Died event. Avoid searching the scene every frame.

Should I pool enemies in a wave game?

Yes, especially on mobile or with large waves. It avoids instantiate costs and garbage collection spikes.

How do I make endless waves?

After the designed waves, generate WaveData from a formula that slowly increases count, spawn rate and enemy variety.

Coroutine or Update for spawning?

A coroutine reads naturally for sequences of waits and is easiest to follow. An Update-based timer works too if you prefer explicit state.

Comments

Leave a Reply

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