Shmup #7: Draw the schedule, then build the wave spawner
unity dev

Shmup #7: Draw the schedule, then build the wave spawner

September 20, 2026
Updated Sep 13, 2026
6 min read
#Unity#Unity 6#Shmup#Tutorial
🚀 Build a shoot ’em up with Unity 68 / 12

1.0 s · Spawn #1

1.8 s · Spawn #2

2.6 s · Spawn #3

4.6 s · Next wave after a 2-second rest

A wave is a schedule, not a pile of objects

Lesson 6 introduced enemy types but still placed them manually. Replace manual placement with a schedule: wait one second, spawn three enemies 0.8 seconds apart, rest two seconds, then continue. Save SEU_07_Waves.

The timeline defines interval only between spawns. After the final enemy, wait only delayAfter, not another unintended interval. Actual times are rounded to the frame where the coroutine resumes; the numbers show the target schedule.

A wave finishes when it has spawned its count, not when all enemies die. “Clear every enemy before proceeding” would be a different condition requiring a separate counter.

Translate the schedule into WaveSet

FieldMeaningExample
enemyType used for the waveInsectBasic
countTotal spawns3
intervalSeconds between spawns0.8
delayAfterSeconds after the last spawn2
loopRepeat the entire listOff for the first test

Create WaveSet:

Assets/_ShootEmUp/Scripts/Data/WaveSet.cs

using System;
using UnityEngine;

namespace ShootEmUp.Data
{
    [CreateAssetMenu(menuName = "ShootEmUp/Wave Set", fileName = "Waves_New")]
    public sealed class WaveSet : ScriptableObject
    {
        [Serializable]
        public struct Wave
        {
            public EnemyData enemy;
            [Min(1)] public int count;
            [Tooltip("Seconds between two spawns inside this wave.")]
            [Min(0f)] public float interval;
            [Tooltip("Seconds to wait after the last spawn of this wave before the next wave starts.")]
            [Min(0f)] public float delayAfter;
        }

        public Wave[] waves = Array.Empty<Wave>();

        [Tooltip("Start again from the first wave when the last one is done.")]
        public bool loop = true;
    }
}

Serializable lets Unity display each Wave in the Inspector. The ScriptableObject stores the schedule; the spawner executes it. Create Waves_Level1 under ScriptableObjects/Waves with only the example wave and Loop disabled.

A coroutine expresses waiting in the code

An ordinary method runs to completion when called. A coroutine runs until yield, returns control, and resumes on an eligible frame. WaitForSeconds uses game time; it does not create a new thread.

Create WaveSpawner:

Assets/_ShootEmUp/Scripts/Enemies/WaveSpawner.cs

using System.Collections;
using BillLab.Common.Pooling;
using ShootEmUp.Core;
using ShootEmUp.Data;
using UnityEngine;

namespace ShootEmUp.Enemies
{
    public sealed class WaveSpawner : MonoBehaviour
    {
        [Tooltip("Pool of the generic enemy prefab.")]
        [SerializeField] private PrefabPool enemyPool;

        [Tooltip("Which waves to play, in order.")]
        [SerializeField] private WaveSet waveSet;

        [Tooltip("How far above the visible top edge enemies appear.")]
        [SerializeField] private float spawnMargin = 1.5f;

        [Tooltip("Keep spawns this far from the left/right edges so big asteroids are not half off-screen.")]
        [SerializeField] private float horizontalPadding = 1f;

        [Tooltip("Seconds before the first wave, so the player can get ready.")]
        [SerializeField] private float initialDelay = 1f;

        private Coroutine running;

        private void OnEnable()
        {
            running = StartCoroutine(Run());
        }

        private void OnDisable()
        {
            if (running != null) StopCoroutine(running);
        }

        private IEnumerator Run()
        {
            if (waveSet == null || waveSet.waves == null || waveSet.waves.Length == 0) yield break;
            yield return new WaitForSeconds(initialDelay);

            do
            {
                foreach (var wave in waveSet.waves)
                {
                    if (wave.enemy == null || wave.count < 1) continue;
                    for (var i = 0; i < wave.count; i++)
                    {
                        Spawn(wave.enemy);
                        if (i + 1 < wave.count) yield return new WaitForSeconds(Mathf.Max(0f, wave.interval));
                    }

                    yield return new WaitForSeconds(wave.delayAfter);
                }
                yield return null;
            }
            while (waveSet.loop);
        }

        private void Spawn(EnemyData data)
        {
            var bounds = ScreenBounds.Get();
            var x = Random.Range(bounds.xMin + horizontalPadding, bounds.xMax - horizontalPadding);
            var position = new Vector3(x, bounds.yMax + spawnMargin, 0f);

            var enemy = enemyPool.Get(position, Quaternion.identity);
            enemy.GetComponent<Enemy>().Apply(data);
        }
    }
}

The inner loop counts enemies; the outer loop walks through waves. OnDisable stops the coroutine when disabling the component, allowing lesson 8 to stop spawning with enabled = false. Re-enabling this version restarts the schedule.

An empty list is rejected so a repeating schedule cannot spin without yielding. Validate this even if your initial asset always contains a wave.

Rent an enemy and choose its location

X is randomized within horizontal bounds minus padding. Y is the camera top plus spawnMargin so enemies enter from offscreen. The pool positions before enabling; Enemy.Apply immediately selects the new configuration.

Delete the manually placed enemies. Create EnemyPool, Prefab = Enemy_Insect, Prewarm 15, Max Size 60. Create WaveSpawner, assign EnemyPool and Waves_Level1, and set Initial Delay 1, Spawn Margin 1.5, Horizontal Padding 1.

WaveSpawner references

Run the three-enemy wave. Count exactly three arrivals, then no more. Disable the component mid-wave and verify no new spawn follows. Establish this before expanding to many randomized types.

Asteroids extend the configuration

Replace EnemyData, Enemy, and EnemyMover with lesson 7's complete versions. They add colliderRadius and rotationSpeed, applying those values to CircleCollider2D and the mover. The archive contains the complete interfaces.

Create three EnemyData assets:

AssetHPSpeedRadius at scale 1Rotation °/sScore
Asteroid_Small140.4905
Asteroid_Medium32.50.554515
Asteroid_Large61.50.852040

Radius uses local units. Adapt it for different artwork and object scale rather than treating it as a fixed world radius. Keep insects at Rotation Speed 0. Rename Enemy_Insect to Enemy_Generic in Unity if desired, making its name match its broader role.

A reproducible six-wave schedule

WaveEnemyCountIntervalDelay After
1InsectBasic40.82
2Asteroid_Small60.52
3InsectFast40.62
4Asteroid_Medium31.02
5InsectBasic50.50.5
6Asteroid_Large10.83

Each row holds one EnemyData. The insect group and large asteroid are consecutive rows, not a hidden mixed-wave setting. Enable Loop after verifying one complete pass.

Finish when counts and rests match the schedule, offscreen enemies are cleaned up, each type receives the right sprite/HP/collider, and disabling the spawner prevents the next spawn. The game now runs continuously; lesson 8 gives a run a clear ending.

Source for this stage

Download all lesson 7 scripts. The archive contains code and assembly definitions, not scenes, prefabs, or the pictured artwork. Assemble the scene using this lesson. When upgrading, replace files at the same paths; never put two versions of a class in Assets.

Next: Shmup #8.