Wave Loop: Combat Pacing
[!NOTE]
Resource Context: This module provides expert patterns for Wave Loops. Accessed via Godot Master.
When to Use
- Building wave-based shooters, tower defense, or arena games in Godot 4.x (optimized for 4.3+).
- Need scalable patterns for managing combat waves, dynamic difficulty scaling, and automated enemy spawning.
- Require data-driven "Encounters" using
Resource files to allow designers to rebalance without touching code.
- Implementing survival modes, endless modes, or scripted RPG encounters with triggered waves.
Trigger keywords: wave, spawn, enemy, difficulty scaling, arena, tower defense, survival, endless, encounter, MultiMesh, object pool, NavigationServer, WaveManager, WaveResource.
Prerequisites
- Godot 4.3 or newer (4.x supported, 4.3+ optimized).
- A project with a 3D scene containing at least one
Marker3D for spawn points.
NavigationRegion3D baked in your arena if using pathfinding.
- Scripts directory present at
res://addons/godot_game_loop_waves/scripts/ (or your project's scripts folder).
Procedure
Architectural Thinking: The "Wave-State" Pattern
A professional implementation treats waves as Data-Driven Transitions. Instead of hardcoding spawn counts, use a WaveResource to define "Encounters" that the WaveManager processes as a state machine.
Core Responsibilities
- WaveManager: The orchestrator. Manages the timeline, handles inter-wave delays, and evaluates "Victory" conditions.
- Spawner: Decoupled spatial nodes that provide the "where" (Marker3D) and the "how" (spawn logic).
- WaveResource: Immutable data containers (Custom Resources) that define enemy types, counts, and difficulty modifiers.
Step 1: Define the WaveResource Data Container
- Create a new script
wave_resource.gd extending Resource with class_name WaveResource.
- Export a
Dictionary for compositions mapping scene paths to counts.
- Export a
difficulty_multiplier: float defaulting to 1.0.
- Export spawn interval and pre-wave delay floats for pacing control.
# wave_resource.gd
extends Resource
class_name WaveResource
@export var compositions: Dictionary = {
"res://Enemies/Goblin.tscn": 10,
"res://Enemies/Orc.tscn": 2
}
@export var difficulty_multiplier: float = 1.0
@export var spawn_interval: float = 0.5
@export var pre_wave_delay: float = 3.0
MANDATORY: Read scripts/wave_resource.gd before implementing this step. It contains the full data container with spawn rates and difficulty settings.
Step 2: Implement the WaveManager Orchestrator
- Create
wave_manager.gd as an Autoload or scene-level Node.
- Maintain an
Array[WaveResource] for linear progression or generate procedurally for endless mode.
- Use
await get_tree().create_timer(pre_delay, true).timeout for inter-wave pacing.
- Emit a
wave_started signal before invoking spawn logic.
- Track active enemy count via a signal-based counter (
enemy_died signal), never by polling get_children().
# wave_manager.gd snippet
func start_next_wave(pre_delay: float):
# Provide "Juice" and preparation time
await get_tree().create_timer(pre_delay, true).timeout
wave_started.emit()
_spawn_logic()
MANDATORY: Read scripts/wave_manager.gd before implementing. It orchestrates the timeline, manages delays between waves, and tracks "Victory" conditions.
Step 3: Set Up Weighted Spawners at Marker3D Nodes
- Place
Marker3D nodes in your arena at desired spawn locations.
- Attach a spawner script that reads from
WaveResource.compositions.
- Use weighted random selection for enemy variety and distribution.
- Spawn via
call_deferred(&"add_child", enemy) — never synchronously inside physics callbacks.
MANDATORY: Read scripts/wave_weighted_spawner.gd before implementing. It provides spatial spawning using weighted random selection for enemy variety and distribution.
Step 4: Implement Object Pooling for High-Frequency Entities
- Pre-instantiate a pool of enemy nodes at scene load (e.g., 50–200 depending on expected concurrency).
- On spawn, pull from the pool and reposition; on death, return to pool and disable collision.
- Use
set_deferred("disabled", true) on CollisionShape immediately upon death.
- Never
instantiate() and queue_free() high-frequency entities.
Step 5: Optimize Swarms with MultiMeshInstance3D
- For trivial enemies (no individual logic), use
MultiMeshInstance3D to batch thousands of instances into a single draw call.
- Update per-instance transforms via
multimesh.set_instance_transform(i, transform).
- Pair with
OccluderInstance3D baked in the arena for occlusion culling of hidden enemies.
MANDATORY: Read scripts/wave_loop_patterns.gd before implementing advanced patterns. It contains 10 expert patterns: MultiMesh swarms, async pathfinding, background preloading, and server-side physics mobs.
Step 6: Configure Async Pathfinding
- Enable
use_async_iterations on NavigationAgent3D nodes attached to enemies.
- For large crowds, use
NavigationServer3D async queries (map_get_path with callback).
- Use separate
NavigationMap instances for flying vs walking enemies to prevent pathing errors.
Step 7: Build Wave UI with Signal-Bus Pattern
- Add a
CanvasLayer for wave counter and health bars.
- Display
current_wave / total_waves updated via signal from WaveManager.
- Use
TextureProgressBar on CanvasLayer for boss health, or Sprite3D with SubViewport texture for world-space bars.
- Always provide a UI countdown or "Wave Incoming" warning before auto-starting waves.
Master Decision Matrix: Progression
| Pattern |
Best For |
Logic |
| Linear |
Story missions |
A sequential Array[WaveResource] processed in order. |
| Endless |
Survival modes |
Procedurally generated WaveResource using exponential growth math. |
| Triggered |
RPG Encounters |
Wave starts only when player enters a specific Area3D trigger. |
Pitfalls
HARD RULES — Never Violate
- NEVER iterate through
get_children() to find all enemies — This is $O(n)$ and slow. Always use get_tree().get_nodes_in_group(&"enemies") or maintain a dedicated Array of active entities.
- NEVER
instantiate() and queue_free() high-frequency entities — This triggers frequent garbage collection and memory fragmentation. Use an Object Pool pattern to reuse nodes.
- NEVER use
MeshInstance3D for swarms — Individual draw calls will tank performance. Use MultiMeshInstance3D to batch thousands of instances into a single draw call.
- NEVER calculate complex pathfinding on the main thread — This causes frame spikes. Use
NavigationServer3D async queries or enable use_async_iterations in the NavigationAgent.
- NEVER
add_child() without verifying is_inside_tree() — If the spawner is being freed, this will crash. Always verify the parent's state or use call_deferred.
- NEVER assign a shared
.tres resource directly to mobs — Modifying a shared resource changes it for all enemies. Always call .duplicate() or .duplicate(true) (deep copy) for unique stats.
- NEVER use standard
String for high-frequency calls — Use StringName (e.g., &"enemies") to utilize Godot's internal string pooling and avoid repeated hashing.
- NEVER spawn entities synchronously inside physics callbacks — Modifying the scene tree during
_physics_process or collision callbacks can corrupt the physics state. Always use call_deferred(&"add_child", enemy).
- NEVER leave
CollisionShape active on dead enemies — Dead bodies will block navigation and other entities. Use set_deferred("disabled", true) immediately upon death.
- NEVER synchronize complex Objects via
MultiplayerSynchronizer — It is designed for primitive types. Sync a uint or StringName ID and resolve the object reference locally on the client.
- NEVER auto-start waves without player feedback — This creates a poor UX. Always provide a UI countdown, a "Wave Incoming" warning, or a manual start button.
- NEVER hardcode spawn coordinates — Use
Marker3D nodes. This allows level designers to move spawn points visually in the editor without editing scripts.
- NEVER poll for wave completion in
_process — Counting children every frame is expensive. Use a signal-based counter (e.g., enemy_died signal) to track active counts.
- NEVER use a single navigation map for all entity types — Flying and walking enemies require different navigation constraints. Use separate
NavigationMap instances to prevent pathing errors.
- NEVER scale
CollisionShape non-uniformly — Non-uniform scaling breaks collision detection math and physics stability. Adjust the shape's internal size/radius properties instead.
Common Runtime Issues
- Frame spikes during wave start: Caused by synchronous instantiation. Fix by spreading spawns across frames using a timer or
call_deferred queue.
- Stutter during high-volume spawning: Caused by GC from
queue_free(). Fix by implementing object pooling.
- Pathing errors for mixed enemy types: Caused by shared navigation map. Fix by creating separate maps via
NavigationServer3D.map_create().
- Shared stat mutation across enemies: Caused by assigning the same
.tres resource. Fix by calling .duplicate(true) before assigning.
Verification
Run through this checklist after implementing:
Quick Runtime Checks (PowerShell)
# Check Godot project file exists
Test-Path "project.godot"
# Run the project headless to validate scene loading (adjust path to your Godot binary)
& "C:\Godot\Godot_v4.3-stable_win64.exe" --path . --quit-after 5
# Search for forbidden patterns in your scripts
Select-String -Path "res://scripts/*.gd" -Pattern "get_children\(\)"
Select-String -Path "res://scripts/*.gd" -Pattern "queue_free\(\)"
Select-String -Path "res://scripts/*.gd" -Pattern "MeshInstance3D"
Replace C:\Godot\Godot_v4.3-stable_win64.exe with your actual Godot executable path.
Examples
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
| Script |
When to Load |
Contents |
scripts/wave_loop_patterns.gd |
Before implementing advanced optimization patterns |
10 Expert patterns: MultiMesh swarms, async pathfinding, background preloading, and server-side physics mobs. |
scripts/wave_manager.gd |
Before building the wave timeline orchestrator |
Orchestrates the timeline, manages delays between waves, and tracks "Victory" conditions. |
scripts/wave_resource.gd |
Before defining wave data containers |
Data containers for wave compositions, spawn rates, and difficulty settings. |
scripts/wave_weighted_spawner.gd |
Before implementing spatial spawn logic |
Spatial spawner using weighted random selection for enemy variety and distribution. |
Related skills
1---2name: godot-game-loop-waves3description: Runs Godot 4 wave combat as data-driven WaveResource encounters plus a WaveManager timeline, weighted Marker3D spawners, object pools, MultiMesh swarms, and async NavigationServer pathing. Trigger on horde nights, TD lanes, or designer-owned encounter Resources in Godot 4. Not for writing a single enemy's behavior tree or non-Godot engines.4---5
6# Wave Loop: Combat Pacing
7
8> [!NOTE]
9> **Resource Context**: This module provides expert patterns for **Wave Loops**. Accessed via Godot Master.
10
11## When to Use
12
13- Building wave-based shooters, tower defense, or arena games in Godot 4.x (optimized for 4.3+).
14- Need scalable patterns for managing combat waves, dynamic difficulty scaling, and automated enemy spawning.
15- Require data-driven "Encounters" using `Resource` files to allow designers to rebalance without touching code.
16- Implementing survival modes, endless modes, or scripted RPG encounters with triggered waves.
17
18**Trigger keywords**: wave, spawn, enemy, difficulty scaling, arena, tower defense, survival, endless, encounter, MultiMesh, object pool, NavigationServer, WaveManager, WaveResource.
19
20## Prerequisites
21
22- Godot 4.3 or newer (4.x supported, 4.3+ optimized).
23- A project with a 3D scene containing at least one `Marker3D` for spawn points.
24- `NavigationRegion3D` baked in your arena if using pathfinding.
25- Scripts directory present at `res://addons/godot_game_loop_waves/scripts/` (or your project's scripts folder).
26
27## Procedure
28
29### Architectural Thinking: The "Wave-State" Pattern
30
31A professional implementation treats waves as **Data-Driven Transitions**. Instead of hardcoding spawn counts, use a `WaveResource` to define "Encounters" that the `WaveManager` processes as a state machine.
32
33#### Core Responsibilities
34- **WaveManager**: The orchestrator. Manages the timeline, handles inter-wave delays, and evaluates "Victory" conditions.
35- **Spawner**: Decoupled spatial nodes that provide the "where" (Marker3D) and the "how" (spawn logic).
36- **WaveResource**: Immutable data containers (Custom Resources) that define enemy types, counts, and difficulty modifiers.
37
38### Step 1: Define the WaveResource Data Container
39
401. Create a new script `wave_resource.gd` extending `Resource` with `class_name WaveResource`.
412. Export a `Dictionary` for compositions mapping scene paths to counts.
423. Export a `difficulty_multiplier: float` defaulting to `1.0`.
434. Export spawn interval and pre-wave delay floats for pacing control.
44
45```gdscript
46# wave_resource.gd
47extends Resource
48class_name WaveResource
49
50@export var compositions: Dictionary = {
51 "res://Enemies/Goblin.tscn": 10,
52 "res://Enemies/Orc.tscn": 2
53}
54@export var difficulty_multiplier: float = 1.0
55@export var spawn_interval: float = 0.5
56@export var pre_wave_delay: float = 3.0
57```
58
59> **MANDATORY**: Read `scripts/wave_resource.gd` before implementing this step. It contains the full data container with spawn rates and difficulty settings.
60
61### Step 2: Implement the WaveManager Orchestrator
62
631. Create `wave_manager.gd` as an `Autoload` or scene-level `Node`.
642. Maintain an `Array[WaveResource]` for linear progression or generate procedurally for endless mode.
653. Use `await get_tree().create_timer(pre_delay, true).timeout` for inter-wave pacing.
664. Emit a `wave_started` signal before invoking spawn logic.
675. Track active enemy count via a signal-based counter (`enemy_died` signal), **never** by polling `get_children()`.
68
69```gdscript
70# wave_manager.gd snippet
71func start_next_wave(pre_delay: float):
72 # Provide "Juice" and preparation time
73 await get_tree().create_timer(pre_delay, true).timeout
74 wave_started.emit()
75 _spawn_logic()
76```
77
78> **MANDATORY**: Read `scripts/wave_manager.gd` before implementing. It orchestrates the timeline, manages delays between waves, and tracks "Victory" conditions.
79
80### Step 3: Set Up Weighted Spawners at Marker3D Nodes
81
821. Place `Marker3D` nodes in your arena at desired spawn locations.
832. Attach a spawner script that reads from `WaveResource.compositions`.
843. Use weighted random selection for enemy variety and distribution.
854. Spawn via `call_deferred(&"add_child", enemy)` — never synchronously inside physics callbacks.
86
87> **MANDATORY**: Read `scripts/wave_weighted_spawner.gd` before implementing. It provides spatial spawning using weighted random selection for enemy variety and distribution.
88
89### Step 4: Implement Object Pooling for High-Frequency Entities
90
911. Pre-instantiate a pool of enemy nodes at scene load (e.g., 50–200 depending on expected concurrency).
922. On spawn, pull from the pool and reposition; on death, return to pool and disable collision.
933. Use `set_deferred("disabled", true)` on `CollisionShape` immediately upon death.
944. Never `instantiate()` and `queue_free()` high-frequency entities.
95
96### Step 5: Optimize Swarms with MultiMeshInstance3D
97
981. For trivial enemies (no individual logic), use `MultiMeshInstance3D` to batch thousands of instances into a single draw call.
992. Update per-instance transforms via `multimesh.set_instance_transform(i, transform)`.
1003. Pair with `OccluderInstance3D` baked in the arena for occlusion culling of hidden enemies.
101
102> **MANDATORY**: Read `scripts/wave_loop_patterns.gd` before implementing advanced patterns. It contains 10 expert patterns: MultiMesh swarms, async pathfinding, background preloading, and server-side physics mobs.
103
104### Step 6: Configure Async Pathfinding
105
1061. Enable `use_async_iterations` on `NavigationAgent3D` nodes attached to enemies.
1072. For large crowds, use `NavigationServer3D` async queries (`map_get_path` with `callback`).
1083. Use separate `NavigationMap` instances for flying vs walking enemies to prevent pathing errors.
109
110### Step 7: Build Wave UI with Signal-Bus Pattern
111
1121. Add a `CanvasLayer` for wave counter and health bars.
1132. Display `current_wave / total_waves` updated via signal from `WaveManager`.
1143. Use `TextureProgressBar` on `CanvasLayer` for boss health, or `Sprite3D` with `SubViewport` texture for world-space bars.
1154. Always provide a UI countdown or "Wave Incoming" warning before auto-starting waves.
116
117### Master Decision Matrix: Progression
118
119| Pattern | Best For | Logic |
120| :--- | :--- | :--- |
121| **Linear** | Story missions | A sequential `Array[WaveResource]` processed in order. |
122| **Endless** | Survival modes | Procedurally generated `WaveResource` using exponential growth math. |
123| **Triggered** | RPG Encounters | Wave starts only when player enters a specific `Area3D` trigger. |
124
125## Pitfalls
126
127### HARD RULES — Never Violate
128
129- **NEVER iterate through `get_children()` to find all enemies** — This is $O(n)$ and slow. Always use `get_tree().get_nodes_in_group(&"enemies")` or maintain a dedicated `Array` of active entities.
130- **NEVER `instantiate()` and `queue_free()` high-frequency entities** — This triggers frequent garbage collection and memory fragmentation. Use an **Object Pool** pattern to reuse nodes.
131- **NEVER use `MeshInstance3D` for swarms** — Individual draw calls will tank performance. Use `MultiMeshInstance3D` to batch thousands of instances into a single draw call.
132- **NEVER calculate complex pathfinding on the main thread** — This causes frame spikes. Use `NavigationServer3D` async queries or enable `use_async_iterations` in the NavigationAgent.
133- **NEVER `add_child()` without verifying `is_inside_tree()`** — If the spawner is being freed, this will crash. Always verify the parent's state or use `call_deferred`.
134- **NEVER assign a shared `.tres` resource directly to mobs** — Modifying a shared resource changes it for all enemies. Always call `.duplicate()` or `.duplicate(true)` (deep copy) for unique stats.
135- **NEVER use standard `String` for high-frequency calls** — Use `StringName` (e.g., `&"enemies"`) to utilize Godot's internal string pooling and avoid repeated hashing.
136- **NEVER spawn entities synchronously inside physics callbacks** — Modifying the scene tree during `_physics_process` or collision callbacks can corrupt the physics state. Always use `call_deferred(&"add_child", enemy)`.
137- **NEVER leave `CollisionShape` active on dead enemies** — Dead bodies will block navigation and other entities. Use `set_deferred("disabled", true)` immediately upon death.
138- **NEVER synchronize complex Objects via `MultiplayerSynchronizer`** — It is designed for primitive types. Sync a `uint` or `StringName` ID and resolve the object reference locally on the client.
139- **NEVER auto-start waves without player feedback** — This creates a poor UX. Always provide a UI countdown, a "Wave Incoming" warning, or a manual start button.
140- **NEVER hardcode spawn coordinates** — Use `Marker3D` nodes. This allows level designers to move spawn points visually in the editor without editing scripts.
141- **NEVER poll for wave completion in `_process`** — Counting children every frame is expensive. Use a signal-based counter (e.g., `enemy_died` signal) to track active counts.
142- **NEVER use a single navigation map for all entity types** — Flying and walking enemies require different navigation constraints. Use separate `NavigationMap` instances to prevent pathing errors.
143- **NEVER scale `CollisionShape` non-uniformly** — Non-uniform scaling breaks collision detection math and physics stability. Adjust the shape's internal size/radius properties instead.
144
145### Common Runtime Issues
146
147- **Frame spikes during wave start**: Caused by synchronous instantiation. Fix by spreading spawns across frames using a timer or `call_deferred` queue.
148- **Stutter during high-volume spawning**: Caused by GC from `queue_free()`. Fix by implementing object pooling.
149- **Pathing errors for mixed enemy types**: Caused by shared navigation map. Fix by creating separate maps via `NavigationServer3D.map_create()`.
150- **Shared stat mutation across enemies**: Caused by assigning the same `.tres` resource. Fix by calling `.duplicate(true)` before assigning.
151
152## Verification
153
154Run through this checklist after implementing:
155
156- [ ] Verify `WaveManager` correctly sequences `WaveResources` with proper delays.
157- [ ] Confirm `WaveResource` compositions use `.duplicate()` for unique enemy stats.
158- [ ] Test that object pooling prevents GC stutters during high-volume spawning.
159- [ ] Validate `MultiMeshInstance3D` batching reduces draw calls for swarms.
160- [ ] Check async pathfinding (`use_async_iterations`) prevents main thread freezes.
161- [ ] Ensure `StringName` (`&"name"`) is used for all high-frequency group operations.
162- [ ] Verify `call_deferred` is used for all physics-callback spawns.
163- [ ] Confirm dead enemies have `CollisionShapes` disabled via `set_deferred`.
164- [ ] Test occlusion culling with `OccluderInstance3D` baked arena.
165- [ ] Validate separate navigation maps for flying vs walking enemies.
166- [ ] Check wave completion uses signal-based counters, not frame polling.
167- [ ] Verify UI countdown/feedback is present before wave auto-starts.
168- [ ] Test `Marker3D` spawn points are adjustable in editor without code changes.
169- [ ] Confirm `MultiplayerSynchronizer` only syncs primitive UIDs/IDs.
170
171### Quick Runtime Checks (PowerShell)
172
173```powershell
174# Check Godot project file exists
175Test-Path "project.godot"
176
177# Run the project headless to validate scene loading (adjust path to your Godot binary)
178& "C:\Godot\Godot_v4.3-stable_win64.exe" --path . --quit-after 5
179
180# Search for forbidden patterns in your scripts
181Select-String -Path "res://scripts/*.gd" -Pattern "get_children\(\)"
182Select-String -Path "res://scripts/*.gd" -Pattern "queue_free\(\)"
183Select-String -Path "res://scripts/*.gd" -Pattern "MeshInstance3D"
184```
185
186> Replace `C:\Godot\Godot_v4.3-stable_win64.exe` with your actual Godot executable path.
187
188## Examples
189
190### Available Scripts
191
192> **MANDATORY**: Read the appropriate script before implementing the corresponding pattern.
193
194| Script | When to Load | Contents |
195| :--- | :--- | :--- |
196| `scripts/wave_loop_patterns.gd` | Before implementing advanced optimization patterns | 10 Expert patterns: MultiMesh swarms, async pathfinding, background preloading, and server-side physics mobs. |
197| `scripts/wave_manager.gd` | Before building the wave timeline orchestrator | Orchestrates the timeline, manages delays between waves, and tracks "Victory" conditions. |
198| `scripts/wave_resource.gd` | Before defining wave data containers | Data containers for wave compositions, spawn rates, and difficulty settings. |
199| `scripts/wave_weighted_spawner.gd` | Before implementing spatial spawn logic | Spatial spawner using weighted random selection for enemy variety and distribution. |
200
201## Related skills
202
203- Master Skill: [godot-master](../godot-master/SKILL.md)