If you’ve ever played a tower defense game or a survival shooter in Roblox, you’ve experienced a wave system in action. Enemies spawn in organized groups, giving players time to prepare between attacks while gradually increasing the challenge. Today, we’re going to build exactly that—a robust spawning and wave system that you can adapt to any game genre.
By the end of this tutorial, you’ll understand how to manage enemy spawns, create escalating waves, and structure your code in a maintainable way. Let’s dive in!
Understanding the Core Components
Before we write any code, let’s break down what a wave system actually needs:
- Wave Manager: Controls the overall flow—when waves start, how many enemies to spawn, and what happens between waves
- Spawn Points: Locations where enemies appear in the game world
- Enemy Tracking: A way to know when all enemies in a wave are defeated
- Difficulty Scaling: Logic to make each wave progressively harder
Understanding these components helps us write modular code where each part has a clear responsibility. This makes debugging easier and lets you modify one aspect without breaking everything else.
Setting Up the Foundation
First, let’s create a ModuleScript called “WaveManager” in ServerScriptService. We’ll use a module because it keeps our wave logic organized and reusable.
-- WaveManager ModuleScript
local WaveManager = {}
WaveManager.CurrentWave = 0
WaveManager.EnemiesRemaining = 0
WaveManager.WaveInProgress = false
WaveManager.SpawnPoints = {}
return WaveManager
These variables track the state of our system. CurrentWave tells us which wave we’re on, EnemiesRemaining helps us know when a wave is complete, and WaveInProgress prevents waves from overlapping. The SpawnPoints table will hold all possible enemy spawn locations.
Finding Spawn Points
Create several parts in your workspace and name them “SpawnPoint”, then group them in a folder called “SpawnPoints”. Now let’s write a function to collect these:
function WaveManager:Initialize()
local spawnFolder = workspace:WaitForChild("SpawnPoints")
for _, spawnPoint in ipairs(spawnFolder:GetChildren()) do
if spawnPoint:IsA("BasePart") then
table.insert(self.SpawnPoints, spawnPoint)
end
end
print("Initialized with", #self.SpawnPoints, "spawn points")
end
We use WaitForChild to ensure the folder exists before trying to access it. The colon syntax (:) in the function definition means this is a method that can access self, which refers to the WaveManager table itself.
Creating the Spawn Logic
Now for the exciting part—actually spawning enemies! We’ll create a function that takes an enemy model and spawns it at a random spawn point.
function WaveManager:SpawnEnemy(enemyModel)
if #self.SpawnPoints == 0 then
warn("No spawn points available!")
return nil
end
-- Pick a random spawn point
local randomSpawn = self.SpawnPoints[math.random(1, #self.SpawnPoints)]
-- Clone the enemy
local enemy = enemyModel:Clone()
enemy.PrimaryPart = enemy:FindFirstChild("HumanoidRootPart") or enemy:FindFirstChildWhichIsA("BasePart")
if enemy.PrimaryPart then
enemy:SetPrimaryPartCFrame(randomSpawn.CFrame + Vector3.new(0, 3, 0))
enemy.Parent = workspace.Enemies
self.EnemiesRemaining += 1
-- Track when this enemy dies
local humanoid = enemy:FindFirstChildWhichIsA("Humanoid")
if humanoid then
humanoid.Died:Connect(function()
self:OnEnemyDefeated()
end)
end
return enemy
end
return nil
end
This function does several important things. We randomly select a spawn point using math.random, clone the enemy model, position it slightly above the spawn point (to prevent it from spawning inside the floor), and connect to the Humanoid’s Died event to track defeats.
Notice we add 3 studs on the Y-axis when positioning. This is a practical detail that prevents spawning issues!
Managing Waves
Now let’s create the heart of our system—the wave management logic. This function calculates how many enemies to spawn and what types based on the wave number.
function WaveManager:StartWave(enemyTemplate)
if self.WaveInProgress then
warn("Wave already in progress!")
return
end
self.CurrentWave += 1
self.WaveInProgress = true
self.EnemiesRemaining = 0
-- Calculate enemy count with scaling
local baseEnemies = 5
local enemyCount = baseEnemies + (self.CurrentWave * 2)
print("Starting Wave", self.CurrentWave, "with", enemyCount, "enemies")
-- Spawn enemies with a slight delay between each
for i = 1, enemyCount do
task.wait(0.5) -- Half second between spawns
self:SpawnEnemy(enemyTemplate)
end
end
The scaling formula baseEnemies + (self.CurrentWave * 2) means wave 1 has 7 enemies, wave 2 has 9, wave 3 has 11, and so on. You can adjust this formula to control your game’s difficulty curve. Want exponential growth? Try baseEnemies * (1.5 ^ self.CurrentWave) instead!
We use task.wait(0.5) to stagger spawns. This prevents all enemies from appearing at once, which looks better and gives players a moment to react.
Handling Enemy Defeats
When an enemy dies, we need to decrease our counter and check if the wave is complete:
function WaveManager:OnEnemyDefeated()
self.EnemiesRemaining -= 1
if self.EnemiesRemaining <= 0 and self.WaveInProgress then
self:CompleteWave()
end
end
function WaveManager:CompleteWave()
self.WaveInProgress = false
print("Wave", self.CurrentWave, "complete!")
-- Wait before starting next wave
task.wait(10) -- 10 second break
-- You can add logic here to check if player wants to continue
-- or automatically start the next wave
end
This creates a natural gameplay loop: spawn enemies, wait for them to be defeated, give players a breather, then start the next wave.
Putting It All Together
Let's create a main script in ServerScriptService that uses our WaveManager:
-- Main game script
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local WaveManager = require(script.Parent.WaveManager)
-- Create an enemies folder in workspace
if not workspace:FindFirstChild("Enemies") then
local enemiesFolder = Instance.new("Folder")
enemiesFolder.Name = "Enemies"
enemiesFolder.Parent = workspace
end
-- Get your enemy template from ReplicatedStorage
local enemyTemplate = ReplicatedStorage:WaitForChild("EnemyModel")
-- Initialize the system
WaveManager:Initialize()
-- Start the first wave
task.wait(3) -- Give players time to prepare
WaveManager:StartWave(enemyTemplate)
-- Auto-start subsequent waves
while true do
if not WaveManager.WaveInProgress then
task.wait(5)
WaveManager:StartWave(enemyTemplate)
end
task.wait(1)
end
Enhancing Your System
Now that you have a working wave system, here are some ways to expand it:
- Multiple Enemy Types: Create a table of different enemy models and randomly select from them, or choose specific enemies for certain waves
- Boss Waves: Every 5th wave could spawn a single powerful boss instead of many weak enemies
- Spawn Patterns: Instead of random spawning, create coordinated attacks from specific directions
- Dynamic Difficulty: Adjust spawns based on how many players are in the game
- Rewards: Give players currency or power-ups between waves
Common Issues and Solutions
Enemies spawning inside the floor: Make sure you're adding that Vector3.new(0, 3, 0) offset when positioning, and check that your spawn points are actually above the ground.
Wave not completing: If an enemy's Humanoid is destroyed before the Died event fires, our counter won't decrease. Consider using AncestryChanged as a backup detection method.
Memory leaks: Always clean up enemy models when they die. Add task.wait(2) followed by enemy:Destroy() in your death handler.
Wrapping Up
Congratulations! You've built a complete wave-based spawning system from scratch. You now understand how to manage game state, spawn entities programmatically, track gameplay progress, and create difficulty scaling—all crucial skills for any Roblox developer.
The beauty of this modular approach is that you can drop this system into any game genre. Tower defense, zombie survival, arena shooters—they all use variations of what you've just learned.
What's Next? Consider learning about RemoteEvents to create a UI that shows players the current wave number and enemies remaining. You could also explore AI pathfinding to make your spawned enemies actually move toward objectives. Both topics will take your wave system from functional to phenomenal!
Happy developing, and may your waves be ever-challenging!