§ How to · with Picoo

How to make a wave spawner in Roblox

By Sametcan Tasgiran, Founder & Developer·Published ·Updated

Wave systems fail in two ways: the curve outruns the players a few waves in, or a single stuck enemy freezes the round forever.

Both are solved by treating the wave as state the server owns: count, health, damage and the clear condition all derived, never accumulated by hand. Picoo's wave_spawner recipe does it in one server Script and one HUD script.

Scaling waves

Enemy count, health and damage each grow on their own multiplier (×1.3, ×1.15 and ×1.1 per wave by default), and walk speed rises 0.4 per wave. Wave 1 is 3 enemies with 50 HP hitting for 10; wave 10 is 31 enemies with 175 HP hitting for 23.

Enemies that chase and hit

Each enemy is a glowing Neon block (red by default; green, blue or purple on request) with a health readout overhead. Every half second it walks toward the nearest player, it deals contact damage through Humanoid:TakeDamage at most once per 0.5 seconds per player, and it bursts into sparks when it dies.

Clear detection by recount

Every half second the server counts the wave's Humanoids that are still in the world with health above zero. The wave clears at zero, so an enemy another script destroys counts as gone.

Rewards per wave

On clear, every player in the server gets 25 coins × the wave number in leaderstats Coins, added on the server.

Wave HUD

Wave number out of 20, the incoming count, enemies left, the coin reward on clear, and VICTORY after the last wave.

Ring spawning, tunable

Enemies spawn evenly spaced on a 30-stud ring around the arena centre (0, 5, 0), 0.15 seconds apart. Waves, growth rates, reward, the 5-second break, ring radius and centre are constants at the top of WaveSpawner and recipe parameters.

Files Picoo ships for this prompt

3 files · 282 lines · one recipe call

ServerScriptService/WaveSpawner.lua

Server Script: wave loop, per-axis scaling, enemy builder, ring spawning, live recount and coin rewards.

216 lines

StarterPlayer/StarterPlayerScripts/WaveHUD.lua

LocalScript: wave number, incoming and remaining enemies, the reward and the victory banner.

66 lines

ReplicatedStorage/Remotes/WaveStateChanged

RemoteEvent the server fires with each wave state; the HUD only displays it.

instance

Sample output: ServerScriptService/WaveSpawner.lua

-- ─── Tunable config ───
local TOTAL_WAVES = 20
local BETWEEN_WAVE_SECONDS = 5
local ENEMIES_PER_WAVE_BASE = 3
local ENEMIES_PER_WAVE_GROWTH = 1.3
local ENEMY_HEALTH_BASE = 50
local ENEMY_HEALTH_GROWTH = 1.15
local ENEMY_DAMAGE_BASE = 10
local ENEMY_DAMAGE_GROWTH = 1.1
local COIN_REWARD_PER_WAVE = 25
local SPAWN_RING_RADIUS = 30
local ARENA_CENTER = Vector3.new(0, 5, 0)

-- (excerpt) spawnEnemy() and the leaderstats helpers sit here; then:

local function runWaveLoop()
	for wave = 1, TOTAL_WAVES do
		local count = math.floor(ENEMIES_PER_WAVE_BASE * (ENEMIES_PER_WAVE_GROWTH ^ (wave - 1)))
		WaveStateChanged:FireAllClients({
			state = "starting", wave = wave, totalWaves = TOTAL_WAVES, enemyCount = count,
		})
		local liveHumanoids: { Humanoid } = {}
		for i = 1, count do
			local angle = (i / count) * math.pi * 2
			local pos = ARENA_CENTER + Vector3.new(
				math.cos(angle) * SPAWN_RING_RADIUS, 0, math.sin(angle) * SPAWN_RING_RADIUS
			)
			local hum = spawnEnemy(wave, pos)
			if hum then table.insert(liveHumanoids, hum) end
			task.wait(0.15)
		end
		while true do
			local aliveCount = 0
			for _, hum in ipairs(liveHumanoids) do
				if hum.Parent and hum.Health > 0 then aliveCount = aliveCount + 1 end
			end
			WaveStateChanged:FireAllClients({
				state = "in_progress", wave = wave, totalWaves = TOTAL_WAVES, enemiesLeft = aliveCount,
			})
			if aliveCount == 0 then break end
			task.wait(0.5)
		end
		awardCoins(COIN_REWARD_PER_WAVE * wave)
		WaveStateChanged:FireAllClients({
			state = "cleared", wave = wave, totalWaves = TOTAL_WAVES, reward = COIN_REWARD_PER_WAVE * wave,
		})
		if wave < TOTAL_WAVES then task.wait(BETWEEN_WAVE_SECONDS) end
	end
	WaveStateChanged:FireAllClients({ state = "victory", wave = TOTAL_WAVES, totalWaves = TOTAL_WAVES })
end

Building a wave spawner in Roblox

A wave system is a difficulty curve with a spawner attached, and the curve is where it lives or dies.

The standard mistake is one multiplier. Difficulty goes up, so count, health and damage all scale by the same growing number, and because they multiply against each other the wave compounds three times over. It feels fine while you test the first three waves and impossible after that. Growing each axis on its own modest multiplier gives a ramp you can actually tune. Picoo's wave_spawner recipe uses 1.3 for count, 1.15 for health and 1.1 for damage, so wave 10 sends 31 enemies with 175 HP each, hitting for 23.

The second failure is subtler: the wave that never ends. If clearing depends on counting deaths, any enemy that leaves without firing Died decrements nothing and the round waits forever. The recipe avoids the counter. Every half second the server walks the list of Humanoids it spawned for the wave and counts the ones still parented with health above zero; when that reaches zero the wave is cleared, it pays out, and after a five-second break the next one starts.

Spawn placement matters more than it looks. Enemies appearing next to the player read as cheating even when the math is fair, because there is no reaction window. The recipe spaces each wave around a 30-stud ring at the arena centre, 0.15 seconds apart, and each enemy then walks toward whoever is nearest.

Everything that decides outcomes stays on the server: when the wave cleared, what it paid (25 coins × the wave number to every player), how much health an enemy had. The HUD is a LocalScript that displays the WaveStateChanged events it receives, not the source of any of it.

See more on the Luau generator, the AI Luau script writer, the 3D model generator and the game builder, read how Picoo compares with ChatGPT, or browse the full blog.

Frequently asked

Why does my wave never end?+

An enemy left without your clear check noticing: it fell out of the world, was destroyed by another script, or never fired Died. If the check decrements a counter on Died, one lost enemy stalls the round permanently. Picoo's WaveSpawner recounts instead: every half second it counts the wave's Humanoids that are still parented and above zero health, so an enemy that was destroyed counts as gone. An enemy stuck alive somewhere players cannot reach still holds the wave; a despawn timer for that is a follow-up prompt.

How fast should difficulty scale?+

Grow count, health and damage on separate multipliers and keep each modest. The recipe's defaults are 1.3 for count, 1.15 for health and 1.1 for damage, which still compounds: wave 10 has 31 enemies and wave 20 has 438. For a long game, ask for a lower enemies_growth or fewer total_waves. A single "difficulty" number multiplying all three at once compounds three times over, which is the most common tuning mistake in wave games.

Should enemies spawn near the player?+

No, spawn them on a ring around the arena. Enemies materialising next to the player read as unfair even when the numbers are fair, and it removes the reaction window that makes waves fun. The recipe spaces each wave evenly around a 30-stud ring at the arena centre; ask for a different spawn_ring_radius or arena_center to fit your map.

Where do rewards belong?+

On the server, on clear. Awarding coins from the client at the end of a wave means the client decides when the wave ended and how much it paid. WaveSpawner adds 25 × the wave number to every player's leaderstats Coins when the wave clears. Saving that balance between sessions is a follow-up prompt.

Waves or rounds — what is the difference?+

A wave spawner escalates continuously against the same players. A round system resets: intermission, match, winner, repeat. If you want a lobby and a winner, that is a mini-game round loop, not a wave spawner.

Related Picoo prompts