Demystifying Tower Defence Game Architecture: A Practical Guide

The steps we need to take to create a plan for the Tower Defence game

Vlad Ogir
13 min readDec 21, 2023
Photo by Anastassia Anufrieva on Unsplash

0. Introduction

Tower defence games have captivated players for years with their strategic gameplay and engaging challenges. I wanted to explore what it might take to architect a tower defence game for many years and this is what I will attempt in this article.

In this article, I am undertaking design for this game for the very first time. I am describing my thinking process and applying some of the tools I use when solving everyday business problems. So, what I am writing in this article is not exclusive to the “Tower Defence”. As part of this article, we’ll explore the game’s entities, use cases, and interactions, uncovering the essential elements that contribute to a cohesive and maintainable architecture. We’ll also touch on architectural concepts such as modularity, abstraction and separation of concerns, demonstrating how these principles can be applied to enhance the game’s design and development process.

After following this article, you’ll be equipped with the knowledge and skills to design and develop your own tower defence game.

1. Understand the game

Let's dive in! The first thing that we need to do is define what the “Tower Defence” is. With the definition at hand, we should have a clearer picture of what we need to create. So, here is my definition of the game:

Tower defence is a game where you have an enemy and a player. Player’s job is to build defensive towers on the map in hope of “clearing” the level. Enemy, the NPC, spawnsenemies. Every time level is cleared, next level gets harder.

From the description, we can quickly derive what entities we have to deal with. Additionally, we can identify some actions: clearing levels, building a tower and enemy spawning.

list of entities

Whilst this information is not actionable it gives us context that will help us build the design for the game further in the article. We could have definitely expanded on the definition a lot more, but by keeping the scope small we are making this game more manageable (we can always make things more complicated later.)

2. Understand use cases

With us now understanding the basics, the next step is to understand the use cases. Use cases will help us see how all the entities and actions are connected and we may also uncover some other building blocks.

In real life it would be up to the team to figure out what those use cases are by talking to stakeholders, but, for this article, I will list some of the possible behaviours that we will experience.

  • Each level defines what enemies to spawn
  • Enemies can be of different types
  • Enemies are spawned on the map
  • Enemy follows a predefined path on the map
  • The player places towers on the map
  • Towers attack enemies

This list is not exhaustive by far, I can list a lot more extras, but I will stick with those to keep the scope narrow. With those behaviours present, we can now map them on a diagram. Having a visual representation should help with identifying any connective pieces necessary to make this game work. I will place the first 2 behaviours on a diagram, one after another, to demonstrate how the diagram would grow.

The player places towers on the map

From the use case, we can derive three entities: the player, the tower and the map. Those can be easily connected.

Connecting three entities (player, map and tower) on the diagram

The first thing that jumps at me when looking at the diagram is that we have a “tower entity” (a single thing), but we expect many towers on the map.

There are several ways of solving this problem, one way is to keep an array of tower entities on the MapEntity. We may use a code similar to the one below to solve this:

interface ITower {
setCoords(coords: ICoord): void
}

interface IMap {
towers: ITower[]

function addTower(tower: ITower): void
function removeTower(coords: ICoord): void
function isValidTowerPlacement(coords: ICoord): bool
}

class Player implements IPlayer {
map: IMap

function placeTower(coords: ICoord, towerClass: string): void {
tower = new towerClass()
tower.setCoords(coords)

if(map.isValidTowerPlacement(coords)) map.addTower(coords, tower)
}
}

This is an OK solution, it definitely works and it might be what we want. However, one difficulty with this path is that we are coupling towers to the map and this will require the MapEntityto have the functionality necessary to manage those towers. Whilst coupling can be justified, the extra functionality can be argued as an additional complexity.

For example, whilst MapEntity is aware of available cells where towers can be placed we may have additional logic related to what towers we can place and how tower placement works. There are also other concerns related to the reusability of placed towers sinceMapEntity is not the only Entity that might want to have access to towers. There is also a question of testing, MapEntity is responsible for the state of the map, adding additional tests will only bloat things and make it harder to follow.

An alternative solution, to reduce the complexity of MapEntity, is instead to have a separate object that will take care of the tower management. With this separation of concerns MapEntity no longer needs to know where towers are placed and can handle what the map consists of instead.

An updated diagram that includes `Tower Manager` class

Each level defines what enemies to spawn

A similar approach to the previous one can apply here, we have a level and enemies. On the level, we can encapsulate level-specific configurations, such as waves of enemies to spawn. Once the config is present we should be able to loop through the config and spawn relevant enemies on the map. By having a configuration we can then decouple level configuration from the logical classes, such as EnemySpawning.

The diagram includes a link between the level entity and the enemy entity

So, from the code perspective we may have something similar to the below:

class ILevel {
name = "Level X"
wave_config = [{
enemy_class: EasyEnemy,
quantity: 10
}, {
enemy_class: HardEnemy,
quantity: 2
}]
}

Wrap up

Simple so far? To fast forward, I ended up with a diagram below. This diagram encapsulates all the use cases that were listed previously. This should now give us a better understanding of what the game consists of and how to solve it. One thing to note is that I added EnemyManager class for the same reason as TowerManager.

The image demonstrates the final outcome of this step

3. Surface attribute in entities

Whilst it is not something that I do in practice, it would just happen in the code, it might be useful to demonstrate that using the previous diagram, we can quickly derive initial attributes and actions for our classes

Table with a list of attributes and actions per class

As I was going through this exercise some of the obvious things that we need to have started popping up in my head. Specifically, it made sense to me to set attributes for the tower entity and the enemy entity, so I marked those in red.

Another thing that you have probably noticed is two actions in blue colour. I’ve highlighted those in blue because they, whilst defined on the diagram as a single link, actually require a lot more to function. I will explain those next.

Spawn Enemies Functionality

Each level has a configured wave of enemies that needs to be spawned. The EnemyManager class needs to receive the enemies. Afterwards, we may want to make the enemy move and an enemy needs to know of the path to follow.

So, as a result, we have multiple entities in the play: level (list of enemies), map (enemy path) and enemy manager (makes enemies do stuff). Having this under the level entity’s responsibility will couple the level entity to other objects. Due to that, it would be worth to have a separate class which will coordinate this process. By separating concerns we can create a class that oversees multiple other classes. I have created a diagram below to illustrate all the data that we need to have access to.

Requests and Data Source Diagram

Based on the diagram above, we may end up with a code similar to the one below.

class SpawnEnemies {
enemy_manager: IEnemyManager

public __construct(): void {
self.enemy_manager = EnemyManager.getInstance()
}

public handle(enemies: ILevelEnemies[], enemy_path: [number, number][]): void {
self.enemy_path = enemy_path

for (let enemy_spawn_config of enemies) {
this.execute_enemy_config(enemy_spawn_config)
}
}

protected execute_enemy_config(config: ILevelEnemies): void {
enemy_spawning_loop = setInterval({
self.spawn_enemy(config.enemy_class)
}, config.spawn_rate_in_seconds * 1000)

setTimeout(() => {
clearInterval(enemy_spawning_loop);
}, config.spawn_rate_in_seconds * config.quantity * 1000);
}

protected spawn_enemy(enemy_class: IEnemy): void {
enemy = new enemy_class()

self.enemy_manager.add(enemy)
// make enemy move
enemy.render(self.enemy_path)
}
}

With the approach above the Game class can trigger the process when the level starts. Also, the Game class is already aware of all the classes on the system (so it’s coupled by default).

Lastly, with the class being stand-alone and having clear inputs, it’s a lot more straightforward to test. For input, we provide a config with the expected enemy path. The outcome is the enemy is added to the manager, and the enemy is then rendered in UI.

But, should we want to, we can reduce the complexity and scope of the test and the code further. Since we have 2 outcomes, it might be worth splitting them up. The enemy_manager performs array operations whilst render perform UI operations. UI testing is of a different complexity, unlike an array. One way of simplifying this would be to push UI functionality out of this class into a separate area that will be responsible for UI. This will allow us to mock the function call.

To take it further we can utilise events for this, which will create further separation of concerns and we would just need to test that the event is being called instead of mocking.

Attack Enemies Functionality

Attacking enemies is very similar, we need to be aware of towers and enemies. For each tower, we need to be aware of enemies in the range and attack the closest. Additionally to this, each tower has a different rate of attack, which makes things slightly more challenging.

There are a number of ways to solve this problem

  • Attack can happen in the game loop, every second we would match towers to the closest enemies, one after another. An issue here is that we have varied rate_of_fire, due to which the game loop might not be appropriate.
  • Towers themselves can have their own loops, this is where rate_of_fire comes in.
  • Additionally, to the previous point, towers can lock into the closest enemy and check if an enemy is still within range and alive. This point does pose the question “What if some enemies move quicker than others, would we want our tower to swap for the new closest enemy?”.
  • To take the last point further, maybe we can have a queue of enemies, this queue will represent the enemies’ current position. We can then use that queue to pick out the most appropriate enemy to attack. With this approach, we have the most up-to-date list of enemies to refer to, which will improve performance by reducing the number of calls that we need to make.

I can keep going down this rabbit hole for quite a bit. As the saying goes, there is more than one way to skin a fish. We just need to pick the most appropriate path for our use case. But, with those examples, I am trying to demonstrate that we don't really know what the future might bring and usually, it’s nice to leave an opening that will allow you (or someone else) to easily expand the system further in the future.

So, how might this look from the code perspective? For demo purposes, I will go with the second option and a separate class. This will give us a code solution that solves the need for a tower attack mechanism and gives us a way of expanding the code in the future at a low cost.

class ActivateTowerAttack() {
public handle(tower: Tower) {
setTimeout(() => {
enemy = self.detect_closest_enemy(tower.location, tower.range)

if (enemy) {
enemy.deduct_life(tower.damage)
}
}, tower.rate_of_fire)
}
}

The code itself can be activated on the TowerManagerclass and be triggered when towers are added. This can potentially use events to further decouple functionality, i.e. when a tower is added an event is fired.

4. UI Rendering

Given that Tower Defence is a graphical game, with moving parts, we require a need for a frontend that will render our elements. In theory, we can have other frontends (CLI or text-based output maybe? 😁), but since I’ve been using TypeScript I will assume that HTML is used for an output.

Currently, we have three elements that need rendering:

  • Tower
  • Enemy
  • Map

Presently, we have one of each, but we may definitely have multiple in the future. Due to that, it would be good to have a UI renderer accessible via each entity. As a result, this will decouple logic from UI representation.

To illustrate how this can work we can look at how we can render a map. Let's assume that the map consists of a 5x5 grid and this grid is stored as a multi-dimensional array that contains chars or empty strings.

Example of array disabled in a table format

This is something that can be easily maintained within the map entity, since it’s just a description of what we want to have on the map. When it comes to rendering, this array will be pushed to the renderer and the renderer, based on the configuration and the browser window, can work out the size of the map and the size of each cell necessary to fill the screen. Afterwards, every cell’s contents will be converted into sprites. So, “P” will be turned into a path, “T” into a tree, “R” into a rock and empty strings will be turned into whatever empty string represents.

There are a lot more things that we can touch on related to UI such as sprite maps, WebGL and libraries (which simplifies UI functionality a lot). However, it is out of scope for this article.

5. Outcome

After all those steps our diagram should be something like the below:

The final diagram from the steps taken in this article

With the help of the diagram and previous sections, we can quite quickly create a set of interfaces from which we can start creating code.

// enemy and a tower
interface IEnemyEntity {
readonly life: number;
readonly speed: number;
public deduct_life(damage: int): void;
public alive(): bool;
}

interface ITowerEntity {
readonly rate_of_fire: number;
readonly damage: number;
readonly range: number;
}

// enemy and tower managers
// IDataManager can be used as a data store
interface IDataManager {
readonly data: any;
add(item: any): void;
remove(index: number): void;
}

interface ITowerManager extends IDataManager {
readonly data: ITowerEntity[]
}

interface IEnemyManager extends IDataManager {
readonly data: IEnemyEntity[]

add(item: IEnemy): void
}

// UI
interface IHtmlRenderer {
public render(): void
}
interface ITowerRenderer implements IHtmlRenderer {}
interface IEnemyRenderer implements IHtmlRenderer {}
interface IMapRenderer implements IHtmlRenderer {}

// Map
interface IMapEntity {
readonly enemy_path: [number, number][]
}

// level setup
interface ILevelEnemies {
readonly enemy_class: IEnemyEntity;
readonly quantity: number;
readonly spawn_rate_in_seconds: number;
}

interface ILevelEntity {
readonly enemies: ILevelEnemies[];
readonly enemies_manager: IEnemyManager;
readonly map: IMap;
}

// Player and Game
interface IPlayerEntity {
readonly towers: ITowerManager;

place_tower(location: [number, number]): void;
}


interface IGameEntity {
readonly player: IPlayerEntity;
readonly map: IMapEntity;
readonly level: ILevelEntity;

start(): void;
spawn_enemies(): void;
}

// Actions
interface ISpawnEnemies {}
interface IActivateTowerAttack {}

6. Conclusion

This is one example of slicing this problem up, there are other ways of doing this job. With this approach, we are slicing things up in a waterfall way. An issue with this approach, which you may have noticed through the article, is that we are trying to predict the future, which can be an impossible task.

This uncertainty about what we may need in the future is what leads to indecisiveness since there are multiple ways to solve problems. Rules such as 80/20 try to show that there is a limit to how much valuable planning we can do in advance, whatever extra we try to squeeze out is most likely to be wrong and requires a lot of effort.

There are other alternative approaches, such as focusing on delivering value first (users really want to have a great tower buying experience) or de-risking first (we lack knowledge of how the combat engine should work). Both options allow us to deliver something to end-users iteratively and let architecture grow organically with the help of tests and continuous feedback.

Even with alternative approaches, it's important to consider future needs, rely on a business strategy (assuming you have one) to guide your decision-making process and create code that can be easily extended to accommodate future growth by using techniques like branch by abstraction. Remember, there’s no one-size-fits-all solution.

I hope this article sparks further curiosity and encourages you to try new ways of solving problems. I’d love to hear your thoughts! Share your questions and insights in the comments below or contact me directly.

Want more articles like this? Subscribe to my email list for exclusive content and updates on my latest work.

--

--

Vlad Ogir

Staff software engineer with passion for software delivery, architecture and design.