DEV Community

Vlad Ogir
Vlad Ogir

Posted on

Tracking Furthest Enemy in a Tower Defence Game using Max Heap

We have multiple enemies on the map, but which enemy should a tower focus on?

Looking at a map as a player, it's an easy question to answer — the furthest enemy within tower range. But it’s not a simple thing to code!

In this article, I will explain how to use the max heap to track the furthest enemy on the map. The answer to this question is the building block towards enabling towers to track enemies more effectively.

This article is part of a larger series of articles where I explore the development of a tower defence game.

Current Implementation

In the existing implementation, whenever an enemy moves on the map, I trigger an event called EnemyMoved. This event then triggers an update of the enemy's position on the map. The code below is for the update function:

function updateAvailableEnemyStore(enemy: Enemy): void {
    if (enemy.getPrevPosition()) {
        const prev_position_key  = `${enemy.getPrevPosition().row}${enemy.getPrevPosition().col}`
        enemySpatialGrid[prev_position_key][enemy.id] = false
        delete enemySpatialGrid[prev_position_key][enemy.id]
    }

    if (enemy.getPosition()) {
        const current_position_key = `${enemy.getPosition().row}${enemy.getPosition().col}`
        enemySpatialGrid[current_position_key][enemy.id] = enemy
    }
}
Enter fullscreen mode Exit fullscreen mode

I used an array enemySpatialGrid that tracks enemies in each cell on the map. This way, I can see what enemies are present within any cell.

Towers also use this array to locate enemies to attack within their range.

This implementation does the job, but there is a key issue: we don’t know if an enemy we pick from a cell is the furthest and within the tower’s range. This is because enemies within cells are in no particular order.

Why Max Heap?

Max Heap can enable us to do the following:

  • Return the furthest enemy with a time complexity of O(1).

  • Computing the furthest enemy when a new enemy is added or distance updated with a complexity O(logN).

  • Removing enemies from the heap at O(logN) time complexity.

Alternatives

A simple alternative is to sort enemies within a cell by distance. Afterwards, a tower can loop through enemies in a cell and get the first enemy within its range.

There are issues with this approach:

  1. Sorting items generally takes O(N logN), this is slower than max heap.

  2. Enemies that we loop through might not be within the tower’s range. It’s quicker to grab the first element from the max heap.

There are other approaches. One example is a balanced binary search tree. However, I will not be covering those.

Intuition

We can tell which enemy is the furthest by looking at the map. But, how can we achieve the same using code?

Figure 1: Map with 4 enemies

One way is to measure the distance the enemy travelled. Then we can compare distances between enemies. The enemy with the longest distance is the furthest.

Implementing Heap

To implement a heap we need an array (the heap) and a dictionary (the positions). The heap will be used to store enemy distances [4,3,2,1]. The positions mapper will map enemies to the indexes in the heap { 1:0, 2:1, 3:2, 4:3 }. This will help us keep distances for each enemy up to date. When the enemy moves, we will update both the heap and positions.

This is how both heap and positions can be demonstrated using a tree and a table:

Figure 2: Furthest enemy is at the top of the heap.

This is our base class for max heap, with the methods that we will implement:

class MaxHeap {
    heap: [number, number][] = [];
    positions: { [key: number]: number } = {};

    constructor() {
        this.heap = [];
        this.positions = {};
    }

    public insertOrUpdate(enemy_id: number, distanceTravelled: number): void;
    public deleteEnemy(enemy_id: number): void;
    public peek(): number;
}

export default MaxHeap;
Enter fullscreen mode Exit fullscreen mode

1. Implementing “adding an enemy”

To add an enemy, we need to do three things:

  1. Add the enemy’s distance to the heap.

  2. Map the enemy to the index in the heap.

  3. Bubble the enemy’s position in the heap by comparing the enemy’s distance to its parent.

When adding an enemy to the heap, we will insert both distance and enemy ID. (This way we will know what enemy distance belongs to.)

The code below shows how an insert can be implemented:

public insertOrUpdate(enemy_id: number, distanceTravelled: number): void {
    const id = enemy_id;

    if (id in this.positions) {
        // TODO: enemy needs to be updated
    } else {
        // add distance to the heap
        const array_length = this.heap.push([enemy_id, distanceTravelled]);

        // map enemy to the index
        const index = array_length - 1;
        this.positions[id] = index;
    }

    // bubble enemy up
    this.bubbleUp(index);
}
Enter fullscreen mode Exit fullscreen mode

Bubbling up distance is a little bit harder. We need to compare the added distance to its parent. If a parent is of lower value, we need to swap a parent with a child.

Below is a visual example of going through the process, building from Figure 2. In the example below we will add a new enemy with id 5 that travelled a distance of 3.5.

1 - Add a new enemy to the back and map the enemy position to index in the heap.

Figure 3: Added new enemy with id 5 and distance 3.5

2 - Check if bubble-up is possible against its parent. A parent needs to be smaller than a child.

Figure 5: Checking if newly added enemy should be bubbled up

3 - Yes, bubble-up is possible, do the swap!

Figure 6: Bubbling newly added enemy up

4 - Now, we must go back to step 2 until the parent is greater than the child.

This is the code for the bubble-up:

protected bubbleUp(index: number): void {
    while (index > 0) {
        const parentIndex = Math.floor((index - 1) / 2);

        if (this.heap[parentIndex][1] < this.heap[index][1]) {
            this.swap(parentIndex, index);
            index = parentIndex;
        } else{
            break
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A parent is stored at the position floor((index — 1)/2). In array [1,2,3], 1 is a parent of both 2 and 3.

We then compare the parent value to the value at the index. If a parent is less than a child, do the swap.

The code for the swap is below:

protected swap(index1: number, index2: number): void {
    [this.heap[index1], this.heap[index2]] = [this.heap[index2], this.heap[index1]];

    this.positions[this.heap[index2][0]] = index2
    this.positions[this.heap[index1][0]] = index1
}
Enter fullscreen mode Exit fullscreen mode

2. Implementing “update enemy position”

An update is done similarly to adding a new enemy. Except we need to know the enemy’s position in the heap. After that, we need to update the distance travelled in the heap and bubble up.

public insertOrUpdate(enemy_id: number, distanceTravelled: number): void {
    const id = enemy_id;

    if (id in this.positions) {
        const index = this.positions[id];
        this.heap[index] = [enemy_id, distanceTravelled];
    } else {
        // previous code for adding enemy here...
    }

    // bubble enemy up
    this.bubbleUp(index);
}
Enter fullscreen mode Exit fullscreen mode

3. Implementing “deleting an enemy”

Generally, items are popped from the heap, but in the game, enemies might be killed before reaching the top.

To achieve deletion, we need to take three steps:

  1. Swap element to be deleted with the last element in the heap.

  2. Pop the last element from the end of the heap.

  3. Bubble down swapped element.

The code for the delete functionality:

protected deleteEnemy(enemy_id: number): void {
    const index = this.positions[enemy_id];

    // swap enemies
    this.swap(index, this.length() - 1);

    // delete enemy
    this.heap.pop();
    delete this.positions[result[0]];

    // bubble down swapped element
    this.bubbleDown(index);
}
Enter fullscreen mode Exit fullscreen mode

In the example below I will take you through visuals of deleting the furthest item in the heap, as shown in the Figure 7.

Figure 7: Deleting furthest enemy from the heap

1 - We swap the first element with the last element.

Figure 8: Swapping enemy with the last enemy

2 - Delete the last element

Figure 9: Enemy with id 1 is no longer on the list

3 - Bubble the enemy down if necessary by comparing the parent to its children. If any of the children are smaller than the parent, bubble down the parent, by swapping the parent with the largest child.

Figure 10: swapping the largest child with the parent

4 - Keep repeating step 3 until bubble down is not possible.

This is the code for the bubble down:

protected bubbleDown(index: number): void {
    while (index < this.length() ) {
        const leftChildIndex = 2 * index + 1;
        const rightChildIndex = 2 * index + 2;

        const leftVal = this.heap[leftChildIndex] ? this.heap[leftChildIndex][1] : -Infinity;
        const rightVal = this.heap[rightChildIndex] ? this.heap[rightChildIndex][1] : -Infinity;

        const targetIndex = leftVal > rightVal ? leftChildIndex : rightChildIndex;
        const target = this.heap[targetIndex]

        if (target && target[1] > this.heap[index][1]) {
            this.swap(targetIndex, index);
            index = targetIndex;
        } else{
            break
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We get indexes for the left and right child. Afterwards, we grab the largest child of the two and compare it to the parent. If the child is greater than the parent, swap values.

4. Implementing “peek”

This is the simplest thing to implement. Since the heap is already sorted, we just need to grab the first element in the heap:

public peek(): [number, number] {
    return this.heap[0];
}
Enter fullscreen mode Exit fullscreen mode

Start using Heap!

Now that we have implemented the heap, we can integrate it into the code!

The original code, within updateAvailableEnemyStore, can now be swapped for a one-liner:

const furthest_enemies = new MaxHeap()

function updateAvailableEnemyStore(enemy: Enemy): void {
    furthest_enemies.insertOrUpdate(enemy.id, enemy.distanceTravelled)
}
Enter fullscreen mode Exit fullscreen mode

There is also an event responsible for the removal of enemies from the maps. This is where heap delete will go.

window.addEventListener("enemyRemoved", event => {
    const enemy: Enemy = event.detail.enemy

    furthest_enemies.deleteEnemy(enemy.id)
});
Enter fullscreen mode Exit fullscreen mode

You can see the outcome of this in the video below:

Conclusion

There it is. We implemented a max heap and then applied it to the game.

Max heap successfully solved the problem. Now we know the furthest enemy on the map. Additionally, the max heap is more performant than the existing implementation.

Lastly, its implementation paves the way to integrating it with towers. This will make it easier for towers to know what enemy to attack next.

Top comments (0)