Updated:

1. Setup (once)

/scoreboard objectives add wave dummy "Wave"
/scoreboard objectives add left dummy
/scoreboard objectives setdisplay sidebar wave
/scoreboard players set #game wave 0

wave holds the current wave and left holds how many enemies remain.

2. Spawn enemies at markers

/execute at @e[type=marker,tag=spawn] run summon zombie ~ ~ ~ {Tags:["enemy"],PersistenceRequired:1b}

One zombie appears at every spawn marker. The enemy tag lets you count only this game's mobs. Place markers with /summon marker ~ ~ ~ {Tags:["spawn"]} around the arena.

3. Count enemies (repeating)

/execute store result score #game left if entity @e[tag=enemy]

Use a repeat block set to always active; the number of tagged enemies goes into left (see execute store).

4. Next wave when all are dead

/execute if score #game left matches 0 run scoreboard players add #game wave 1
/execute if score #game left matches 0 run title @a title {"text":"Next wave!","color":"red"}
/execute if score #game left matches 0 at @e[type=marker,tag=spawn] run summon zombie ~ ~ ~ {Tags:["enemy"],PersistenceRequired:1b}

Chain these after step 3. Add lines with if score #game wave matches 5.. for tougher mobs; see zombie commands.

5. Game over

/execute unless entity @a[gamemode=adventure] run tellraw @a [{"text":"Game over! Wave reached: ","color":"red"},{"score":{"name":"#game","objective":"wave"}}]

Switch fallen players to spectator so the check works, then clear enemies with /kill @e[tag=enemy].

6. Tougher enemies in later waves

# add after the chain in step 4
execute if score #game left matches 0 if score #game wave matches 5.. at @e[type=marker,tag=spawn] run summon skeleton ~ ~ ~ {Tags:["enemy"],PersistenceRequired:1b}

From wave 5, skeletons join the zombies. Step 4 raises the wave number first, so this line sees the new wave. Add a 10.. line for even later waves.

7. A boss on wave 10 (Java 1.21.5+)

execute if score #game left matches 0 if score #game wave matches 10 at @e[type=marker,tag=spawn,limit=1] run summon zombie ~ ~ ~ {Tags:["enemy"],PersistenceRequired:1b,CustomName:"Boss",CustomNameVisible:1b,attributes:[{id:"minecraft:max_health",base:100}],Health:100f}

Only wave 10 brings one named zombie with 100 health; limit=1 keeps it to one boss. On 1.21.4 and older write CustomName:'"Boss"'. See the boss mob command.

Troubleshooting

Check your command

The Command Error Doctor checks common syntax mistakes in your browser.