Tower defense in GDScript: enemies as arrays, targeting buckets, and waves built from a budget

Tower defense looks simple, but it gets expensive fast: hundreds of enemies on a path, and every tower looking for a target every frame. Here’s how I structured one in plain GDScript, in case it helps anyone making their own.

1. The battle never draws anything

One battle is a plain RefCounted class called Sim. It runs in fixed steps of 1/60 of a second, so the result is the same at any frame rate or game speed:

func step() -> void:
	if over:
		return
	time += STEP
	_spawn_due()
	_move()
	_rebuild_buckets()
	_fire_towers()
	_move_shots()
	_remove_dead()
	_check_waves()

The screen calls advance(seconds) every frame and reads a list of events (shots fired, hits, kills, leaks) to play effects and sounds. The tests and the bot drive the same object, so they can play hundreds of battles in a few minutes.

2. Enemies are arrays, not nodes

Enemies are a structure of arrays, with one entry per enemy: e_pos, e_hp, e_dist, e_slow and so on. Moving the crowd is one loop with no scene tree work. Removing an enemy swaps the last one into its slot.

That means an enemy’s index can change, so anything that follows an enemy over time has to remember something stable. Shots keep the target’s uid, which never changes, and look up its index when they need it.

3. Towers only look at nearby buckets

The field is split into 80-pixel buckets, and every enemy is sorted into one with a counting sort into a flat array, rebuilt every step. A tower only checks the buckets its range touches:

func enemies_near(p: Vector2, radius: float, ground := true, air := true) -> Array[int]:
	var out: Array[int] = []
	var r2 := radius * radius
	var x0 := clampi(int((p.x - radius) / BUCKET) + 1, 0, _bw - 1)
	var x1 := clampi(int((p.x + radius) / BUCKET) + 1, 0, _bw - 1)
	var y0 := clampi(int((p.y - radius) / BUCKET) + 1, 0, _bh - 1)
	var y1 := clampi(int((p.y + radius) / BUCKET) + 1, 0, _bh - 1)
	for by in range(y0, y1 + 1):
		for bx in range(x0, x1 + 1):
			var k := by * _bw + bx
			for j in range(_bstart[k], _bstart[k + 1]):
				var i := _bitems[j]
				if e_hp[i] <= 0.0:
					continue
				var fly := e_data[i].flying
				if (fly and not air) or (not fly and not ground):
					continue
				if e_pos[i].distance_squared_to(p) <= r2:
					out.append(i)
	return out

Splash damage and chain lightning use the same function. In the stress test, 600 enemies and 30 towers cost about 1 ms per step.

4. Target modes are one number

First, Last, Strong and Close aren’t four code paths. Each mode just scores a candidate, and the highest score wins:

match t.mode:
	Mode.FIRST:
		v = e_dist[i]
	Mode.LAST:
		v = -e_dist[i]
	Mode.STRONG:
		v = e_hp[i] + e_dist[i] * 0.001
	Mode.CLOSE:
		v = -e_pos[i].distance_squared_to(t.pos)

5. Waves come from a budget

Rather than hand-writing every wave, each wave gets a budget and each enemy costs its threat:

var budget := (enc.budget_start + enc.budget_growth * w) * scale
var groups := 1 if w < 2 else (2 if w < 5 else rng.randi_range(2, 3))

The first two waves are one group, waves 3 to 5 are two, and later waves are two or three. Each group picks an enemy that fits its share, preferring a kind the wave doesn’t have yet, so mixed waves really mix. A harder battle just means bigger numbers in the encounter resource, and the same seed always gives the same waves.

6. Towers are data, plus a bot to check the balance

Towers, enemies, encounters and modules are resources. A new tower is a duplicated .tres file with its stats filled in, and as long as it uses an existing kind (bullet, splash, frost, chain or beam) it shows up in the build list with no code.

A bot plays battles and whole runs on the same Sim, which makes it a quick way to see what a balance change did. The stress test has it play 60 battles and 12 full runs, checking the rules after every step.


I’ve packaged this up as Tower Defense Kit, a complete roguelite tower defense template for Godot 4: six towers with three levels each, eight enemies including a boss, random maps, an 8-step branching run with shops and rest stops, 16 modules, and mouse, keyboard and gamepad support:

There’s a free browser demo if you want to try it first: Tower Defense Kit - Playable Demo by Spare Parts Studio

Happy to answer questions about any of it.