AI Director

procedural encounters

visual mini-map debugging

adapting to player stress

Key Achievements:

  • Weighted sorting, selecting optimal horde spawn zone

  • Dynamically adapting to player skill and stress

  • Integrated with FMOD audio system

  • Optimized through adaptive grid and spatial partitioning

  • Performant, only spawning enemies in player active area

  • Node-scriptable and JSON data driven, allowing for level designer creativity

  • Many visual debugging functions helping animators, programmer and levels designers alike, using Dear ImGui

Introduction & Motivation

I wrote this AI-Director as part of my contribution to the game Disconnec-Dead, a zombie shooter heavily inspired by Left 4 Dead. My director was integrated in my teams custom C++ game engine and battle tested in the actual game. I spent about 4 weeks of my own time on this project.

“Losing control is scarier than losing health” was a quote I stumbled upon while researching Left 4 Dead, which happens to be one of my favorite games. This became my main motivation when creating the system.

My AI-Director helped achieve this sense of losing control by procedurally populating the maps with enemies, dynamically spawning hordes of enemies and adapting the difficulty based on the player’s stress level. Thus creating a game where every play through was different from the other and where my colleagues did not need to hand place enemies in a tedious and predictable way. 

During the development of this system additional features came to life. Such as a live updated mini-map for debug purposes made with the Dear ImGui library, data driven aspects such as node scripting support to enable level designer to override the Director for scripted events, integration with FMOD for dynamic music changes, and more.

Mapping the World

Each time a new map is loaded the AI-Director is re-initialized. The first step in the initialization process consists of calculating how big the world is, and therefore how big of a grid is needed to spatially partition the world.

The layers on the mini-map

The actual level seen from above, highlighted with debug lines

To map the world the AI-Director loops through all the nodes of the NavMesh generated for that map. During this process it saves the lowest and highest XYZ-coordinate value it can find on any of the nodes. Thus when director is able to create a grid only as big as it needs to be to map the world.

Show Code

bool Forge::AIDirector::CreateGridAndMapWorld()
{
// small snippet of whole function...
	myGrid.clear();
	auto& navMeshNodes = myNavmesh->GetNodes();
	if (navMeshNodes.empty()) { return false; }
	mySmallestMapPosition = {};
	myLargestMapPosition = {};

	for (auto& object : navMeshNodes)
	{
		const Tga::Vector3f pos = object.myCenter;

		mySmallestMapPosition.x = std::min(pos.x, mySmallestMapPosition.x);
		mySmallestMapPosition.y = std::min(pos.y, mySmallestMapPosition.y);
		mySmallestMapPosition.z = std::min(pos.z, mySmallestMapPosition.z);

		myLargestMapPosition.x = std::max(pos.x, myLargestMapPosition.x);
		myLargestMapPosition.y = std::max(pos.y, myLargestMapPosition.y);
		myLargestMapPosition.z = std::max(pos.z, myLargestMapPosition.z);
	}

	myAverageMapHeight = (mySmallestMapPosition.y + myLargestMapPosition.y) * 0.5f;

	Tga::Vector3f playerPos = myData.player->GetTransform().GetPosition();
	Tga::Vector3f endPos = myLevelEnd->GetTransform().GetPosition();

	const float gridXDimension = abs(mySmallestMapPosition.x - myLargestMapPosition.x);
	const float gridZDimension = abs(mySmallestMapPosition.z - myLargestMapPosition.z);
	constexpr float safeMargin = 1.05f;
	myTotalGridSideLength = std::max(gridXDimension, gridZDimension) * safeMargin;
	constexpr uint16_t margin = 3;
	myNumLines = margin + static_cast<uint16_t>(myTotalGridSideLength / myGridCellSideLength);

	myAmountOfColumns = static_cast<int>(myNumLines);
	myAmountOfRows = static_cast<int>(myNumLines);
	myNumberOfCells = myAmountOfColumns * myAmountOfRows;
	myGrid.resize(myNumberOfCells);

	for (int row = 0; row < myAmountOfRows; ++row)
	{
		for (int column = 0; column < myAmountOfColumns; ++column)
		{
			int const nodeIndex = (row * myAmountOfRows) + column;
			myGrid[nodeIndex].id = nodeIndex;
		}
	}
}
    

Populating the World

Main paths, Other paths and Threat zones

The thing that makes Left 4 Dead exciting is the endless variation regarding where and when you encounter enemies and bosses. To achieve this effect my AI-Director uses the NavMesh to get the fastest way from the player starting position to the level end position. All the grid cells along this path get the status of main path which results in them having a higher enemy spawn count. All other walkable cell gets the status of other path which results in a lower spawn count

The spawn count per cell  is always randomized each playthrough but weighted to a higher or lower number depending on the status of that cell. This way the player gets more resistance as she/he moves i the right direction, and less resistance when on the wrong path. This is a dynamic way to give feedback to the player. Areas that makes the game mover forward are more fun and challenging compared to areas that areas that don’t.

Finally there are the threat zones, which are areas where bosses can spawn. The bosses are placed in different spots along the main path for each time the map is loaded, keeping the player on hers/his toes, never knowing when the boss fights will take place.

Each time a map is loaded boss-spawn zones are reassigned.

Using all walkable nodes in the NavMesh, the director creates the first layer of the world named “Other Path” and assignes a enemy spawn count to each grid cell.

Show Code

for (auto& node : navMeshNodes)
{
	const Tga::Vector3f& center = node.myCenter;
	int index = GetCellIndexFromPosition(center);
	myGrid[index].status = CellStatus::OtherPath;

	for (auto neighbor : node.myConnections)
	{
		if (neighbor != -1)
		{
			const Tga::Vector3f neibPos = navMeshNodes[neighbor].myCenter;
			Tga::Vector3f toNeighbor = (neibPos - center);
			const int cellSteps = static_cast<int>(toNeighbor.Length() / myGridCellHalfLength);
			toNeighbor.Normalize();
			for (int step = 0; step < cellSteps; ++step)
			{
				const float floatStep = static_cast<float>(step);
				const Tga::Vector3f betweenPos = center + toNeighbor * myGridCellSideLength * floatStep;
				index = GetCellIndexFromPosition(betweenPos);

				myGrid[index].status = CellStatus::OtherPath;
			}
		}
	}
}

for (auto& cell : myGrid)
{
	if (cell.status == CellStatus::OtherPath)
	{
		cell.zCount = static_cast<int8_t>(randomGenerator->GenerateRandomInt(NULL_CHANCE_SPAWN, OTHER_PATH_MAX_SPAWN));
	}
}
    

This process repeats for the nodes that the player is mostly likely to walk on, “Main Path”, when travelling towards the goal/end of the map. These grid cells get a higher enemy spawn count.

Show Code

myRoughPath = myNavmesh->AStarPathfind(myData.player->GetTransform().GetPosition(), myLevelEnd->GetTransform().GetPosition());
if (!myRoughPath.empty())
{

	for (int main = 0; main < myRoughPath.size(); ++main)
	{
		const int index = GetCellIndexFromPosition(myRoughPath[main]);
		myGrid[index].status = CellStatus::MainPath;

		if (const int neighborIndex = main + 1; neighborIndex < myRoughPath.size())
		{
			Tga::Vector3f toNeighbor = (myRoughPath[neighborIndex] - myRoughPath[main]);
			const int cellSteps = static_cast<int>(toNeighbor.Length() / myGridCellHalfLength);
			toNeighbor.Normalize();

			for (int step = 0; step < cellSteps; ++step)
			{
				const float floatStep = static_cast<float>(step);
				const Tga::Vector3f betweenPos = myRoughPath[main] + toNeighbor * myGridCellSideLength * floatStep;
				const int cell = GetCellIndexFromPosition(betweenPos);

				myGrid[cell].status = CellStatus::MainPath;
			}
		}
	}

	for (auto& cell : myGrid)
	{
		if (cell.status == CellStatus::MainPath)
		{
			cell.zCount = static_cast<int8_t>(randomGenerator->GenerateRandomInt(0, MAIN_PATH_MAX_SPAWN));
		}
	}
}
// ...

    

Lastly the boss spawn zones, or “Threat Zones”, are randomized and place along the main path …

Show Code

// randomization of boss spawn zones
	const int triangleCount = static_cast<int>(myRoughPath.size() - 1);
	constexpr int subdivisions = 16;
	int spacing = triangleCount / subdivisions;
	std::vector<int> multipliers;

	// starting subdivision higher than one, as not to place threat zone directly att player spawn.
	// each iteration is then incremented by two as to lower the risk of two bosses ending upp exactly next each other
	for (int subDiv = 4; subDiv < subdivisions + 1; subDiv += 2)
	{
		multipliers.emplace_back(subDiv);
	}

	std::ranges::shuffle(multipliers, Locator::GetRandomNumberGenerator()->myRandomState.GetMyRandomState());

	std::vector<SpecialEnemy> specialEnemies;

	for (int sE = 0; sE < static_cast<int>(SpecialEnemy::Count); ++sE)
	{
		specialEnemies.emplace_back(static_cast<SpecialEnemy>(sE));
	}

	// the deck of bosses are shuffled, including a none-card as to make each boss encounter unpredictable
	std::ranges::shuffle(specialEnemies, Locator::GetRandomNumberGenerator()->myRandomState.GetMyRandomState());

	for (int mult = 0; mult < THREAT_ZONE_AMOUNT; ++mult)
	{
		int threatZoneIndex = multipliers[mult] * spacing;
		int gridIndex = GetCellIndexFromPosition(myRoughPath[threatZoneIndex]);
		myGrid[gridIndex].status = CellStatus::ThreatZone;
		myGrid[gridIndex].zCount = 0;

		SpecialEnemy special = specialEnemies.back();
		specialEnemies.pop_back();
		myThreatZones.emplace_back(myRoughPath[threatZoneIndex], myGrid[gridIndex].id, special);
	}

    

… along with “Decompression Zones” placed by the level designers and the player spawn zone. The director how has all it’s base data, needed to create a challenge for the player.

Show Code

for (auto& decompressZone : myDecompressZones)
{
	const Tga::Vector3f gridPos = GetXZPositionFromIndex(GetCellIndexFromPosition(decompressZone.second));
	const float squaredRadius = decompressZone.first * decompressZone.first;
	for (auto& cell : myGrid)
	{
		if (cell.status != CellStatus::Invalid)
		{
			const float squaredDistance = (gridPos - GetXZPositionFromIndex(cell.id)).LengthSqr();

			if (squaredDistance < squaredRadius)
			{
				cell.status = CellStatus::DecompressZone;
			}
		}
	}
}

myGrid[GetCellIndexFromPosition(myData.player->GetTransform().GetPosition())].status = CellStatus::PlayerSpawnZone;

    

The Active Area Set

As the player moves through the world, the AI-Director keeps track of which grid cells are entered and exited by the player. Using this information the AI-director can spawn and de-spawn enemies, creating an illusion of a world with hundreds of enemies around every corner. In fact there are never more than 40 enemies spawned at the same time. This is huge win from optimization point of view.

To keep the world consistent the director keeps track of how many enemies each part of the world is supposed to have and respawns that amount if the player were to return to that area.

Spawning and de-spawning enemies based on the active area of the player (light blue).

Concept art of brain rot zombies

Enemy concept art by Taras Kinley

Show Code

// data deciding what action the director should take 
struct AIDCell
{
    uint32_t id = INVALID_ID;
    int8_t zCount = 0;
    CellStatus status = CellStatus::Invalid;
};
    

Show Code

enum class CellStatus : uint8_t
{
    Invalid,
    MainPath,
    OtherPath,
    ThreatZone,
    PlayerSpawnZone,
    Count
};
    

Show Code

void Forge::AIDirector::UpdateActiveArea(float)
{
	// calculate entered and exited area sets
	const Tga::Vector3f playerPos = myData.player->GetTransform().GetPosition();

	const int playerCell = GetCellIndexFromPosition(playerPos);
	const AreaSet currentSet = CalculateActiveAreaCells(playerCell);
	const AreaSet tempExited = myActiveArea - currentSet;
	const AreaSet tempEntered = currentSet - myActiveArea;
	myActiveArea = currentSet;

// ...

    

First we check whether there has been any changes to the players active area at all.

If so, we de-spawn all the enemies in the exited area.

Show Code

if (tempExited.Size() > 0)
{
	myExitedArea = tempExited;
	std::vector<Object*> toDespawn;
	toDespawn.reserve(myExitedArea.Size() * MAIN_PATH_MAX_SPAWN);

	for (auto zombie : myEnemies)
	{
		if (!zombie) continue;
		const int zombieCell = GetCellIndexFromPosition(zombie->GetTransform().GetPosition());
		auto controller = zombie->GetComponent<CommonController>();
		const CommonState state = controller->GetCurrentStateId();

		// only despawn if not path finding to player...
		if (myExitedArea.Contains(zombieCell) && (state == CommonState::Idle || state == CommonState::Wander))
		{
			toDespawn.push_back(zombie);
		}
	}
	// despawn zombies in exited area
	for (auto despawn : toDespawn)
	{
		DespawnEnemy(despawn);
	}
}


    

Lastly we spawn in enemies in the recently entered area. Basing the amount of enemies spawned on each cells spawn count, keeping the world consistent if the player ever were to return to this area again.

Show Code

if (tempEntered.Size() > 0)
{
	myEnteredArea = tempEntered;
	std::vector<Object*> toSpawn;
	toSpawn.reserve(ENEMY_MAX_TOTAL_AMOUNT);

	// spawn new enemies in recently entered area, granted player stress level is not to high.
	if (myData.currentPhase != Phases::Relax && myData.currentPhase != Phases::PeakFade)
	{
		for (auto zombie : myEnemies)
		{
			if (zombie && !zombie->CheckIfActive())
			{
				toSpawn.emplace_back(zombie);
			}
		}

		if (!toSpawn.empty())
		{
			for (const int cellID : myEnteredArea.GetCells())
			{
				const int8_t zCount = myGrid[cellID].zCount;

				if (zCount < 1 || myGrid[cellID].status == CellStatus::PlayerSpawnZone || myGrid[cellID].status == CellStatus::DecompressZone)
				{
					continue;
				}

				for (int8_t spawnCount = 0; spawnCount < zCount; spawnCount++)
				{
					const Tga::Vector3f spawnPoint = GetXZPositionFromIndex(cellID);
					Object* const ready = toSpawn.back();
					toSpawn.pop_back();
					SpawnEnemy(ready, spawnPoint);
					++myData.totalActiveEnemies;
					if (toSpawn.empty() || myData.totalActiveEnemies >= ENEMY_MAX_SPAWN)
					{
						break;
					
					}
				}

				if (toSpawn.empty() || myData.totalActiveEnemies >= ENEMY_MAX_SPAWN)
				{
					break;
				}
			}
		}
	}
}
    

Hordes & Phases

“The Director is not concerned with the player winning or losing, rather it is a question of keeping pace.” was another quote I found while researching Left 4 Dead.

One of the most memorable experiences in Left 4 Dead is when the horde spawns. The size of the horde, how often they attack and from where is decided by many factors. Primarily how well the player handles themselves and what spawn zone the AI-director decides is optimal for the horde to drive the player forward.

My Director uses a technique similar to Left 4 Dead. The spawn zones for hordes are calculated with a weighted sorting algorithm that takes into account:

  • Distance to player (closer is better)

  • If spawn zone if behind the player relative to the level end/goal position.

  • If the spawn zone is on the main path

  • If the spawn zone is out of the view frustum.

This weighting ensures that the horde more often than not spawns in such a way that they chase the player towards the goal of the map.

Procedurally generated horde attacks coming from different angles

The spawn zone’s ratings are visualized with white, gray and black circles, brighter being better

To calculate great spawn zones, naturally the director first gets rid of the impossible ones. Discriminating particularly against zones directly visible from the players point of view.

Show Code

// Discern possible horde spawn zone before next wave
myPossibleHordeSpawnZones.Clear();
myPossibleHordeSpawnZones = myActiveArea;
myPossibleHordeSpawnZones += myExitedArea;

AreaSet impossibleZones;
impossibleZones.Insert(playerCell);

const Tga::Vector3f playerCellPosition = GetXZPositionFromIndex(playerCell);
for (const int cell : myPossibleHordeSpawnZones.GetCells())
{
	// minimum distance check
	const Tga::Vector3f otherCellPosition = GetXZPositionFromIndex(cell);
	const Tga::Vector3f cellToPlayer = (playerCellPosition - otherCellPosition);
	const float distance = cellToPlayer.Length();

	// lower number means more generous inclusion
	if (distance < (myActiveAreaRadius * HORDE_AREA_FIDELITY))
	{
		impossibleZones.Insert(cell);
		continue;
	}

	const Tga::Vector3f cellToPlayerNormalized = cellToPlayer.GetNormalized();
	constexpr float traceFidelity = 0.25f; // lower number means higher fidelity;
	const int steps = static_cast<int>(distance / (myGridCellHalfLength * traceFidelity));
	const float increment = distance / static_cast<float>(steps);


	// check if player can see the spawn zone by quickly tracing a line from the zone to the player
	bool isPossible = false;

	for (int step = 0; step < steps; ++step)
	{
		const int cellToCheck = GetCellIndexFromPosition(otherCellPosition + (cellToPlayerNormalized * increment * static_cast<float>(step + 1)));
		if (myGrid[cellToCheck].status == CellStatus::Invalid)
		{
			isPossible = true;
			break;
		}
	}

	if (!isPossible)
	{
		impossibleZones.Insert(cell);
	}

}

myPossibleHordeSpawnZones -= impossibleZones;
    

It then takes the five zones closest to the player…

Show Code

auto sortClosestToPlayer = [this, playerCellPosition](const int cellA, const int cellB)
	{
		const float lengthSqrToA = (playerCellPosition - GetXZPositionFromIndex(cellA)).LengthSqr();
		const float lengthSqrToB = (playerCellPosition - GetXZPositionFromIndex(cellB)).LengthSqr();
		return lengthSqrToA < lengthSqrToB;
	};

if (myPossibleHordeSpawnZones.GetCells().empty()) return;

std::ranges::sort(myPossibleHordeSpawnZones.AccessCells().begin(), myPossibleHordeSpawnZones.AccessCells().end(), sortClosestToPlayer);

// only keep the closest ones
const int maxZones = std::clamp(static_cast<int>(myPossibleHordeSpawnZones.GetCells().size()), 0, SPAWN_ZONE_ROOF);

if (maxZones == 0) { return; }

myPossibleHordeSpawnZones.AccessCells() = std::vector(myPossibleHordeSpawnZones.GetCells().begin(), myPossibleHordeSpawnZones.GetCells().begin() + maxZones);
    

… and then does a weighted ranking based on if the zone is behind the player from a path finding perspective as well as a view frustum perspective.

Show Code

std::vector<std::pair<int, size_t>> cellWithFlowDistance;

cellWithFlowDistance.reserve(myPossibleHordeSpawnZones.Size());

const Tga::Vector3f levelEnd = myLevelEnd->GetTransform().GetPosition();

for (const int cell : myPossibleHordeSpawnZones.GetCells())
{
	const size_t flowDistance = myNavmesh->AStarPathfind(GetXZPositionFromIndex(cell), levelEnd).size();

	cellWithFlowDistance.emplace_back(cell, flowDistance);

}

const size_t playerFlowDistance = myNavmesh->AStarPathfind(playerPos, myLevelEnd->GetTransform().GetPosition()).size();

auto weightedSort = [this, playerFlowDistance, cellWithFlowDistance](const int cellA, const int cellB)
	{

		auto scoreCell = [this, playerFlowDistance, cellWithFlowDistance](const int aCell, int& aScore)
			{

				auto iterator = std::ranges::find_if(cellWithFlowDistance.begin(), cellWithFlowDistance.end(), [aCell](const std::pair<int, size_t>& pair) {return pair.first == aCell; });
				// behind player relative to goal
				if (iterator != cellWithFlowDistance.end() && iterator->second > playerFlowDistance)
				{
					++aScore;
				}
				// not in view frustum, i.e player is facing a way from spawn zone
				if (!CheckIfXZPlanePosInFrustum(myPlayerFrustum, GetXZPositionFromIndex(aCell)))
				{
					++aScore;
				}
				// on main path
				if (myGrid[aCell].status == CellStatus::MainPath)
				{
					++aScore;
				}

			};

		int scoreA = 0;
		int scoreB = 0;

		scoreCell(cellA, scoreA);
		scoreCell(cellB, scoreB);

		return scoreA > scoreB;
	};

std::ranges::sort(myPossibleHordeSpawnZones.AccessCells().begin(), myPossibleHordeSpawnZones.AccessCells().end(), weightedSort);
    

Making the Director Dance

For the world to feel more alive, the AI-Director should not only calculate unforeseeable spawn zones for hordes. It should also make these hordes vary in size and time intervals. This is done by giving the AI-Director phases.

When the player spawns, the director is kept in the Relax phase until the player leaves the spawn area. In then enters a Build Up phase, spawning idle and wandering enemies in the player active area set.

After about a minute the director enter it’s Peak phase. During this phase hordes will start to spawn. For each horde that is spawned, during this phase, the time until the next horde and the size of that horde is decided based upon the player’s stress level. Naturally if the player has low stress the hordes are bigger and come more often, and vice versa.

Player stress is calculated based how much damage the player has taken and how many enemies that have been killed close up.

If the player’s stress goes to max, or if the Peak phase been going for to long, the director returns to the Peak Fade phase and then to the Relax phase. Not spawning any new hordes or even idle enemies in the world. Leaving free space and time for the player to recuperate.

It should be noted that the phases of the AI-Director also affects the music intensity. This achieved pairing the switching of phases with the FMOD interface I set up for our game engine.

The Director switches between phases and affecting both gameplay and music. (Sound on)

Show Code

void Forge::AIDirector::UpdateCurrentPhase(float aFixedTime)
{
	switch (myData.currentPhase)
	{
	case Phases::Relax:
	{
		myData.relaxTimer -= aFixedTime;
		if (myData.relaxTimer < 0.f && myData.playerLeftSpawn)
		{
			myData.relaxTimer = DirectorStaticData::RELAX_TIMER_RESET;
			myData.currentPhase = Phases::BuildUp;
			Locator::GetAudioManager()->SetParameter(FmodId::MainMusic, static_cast<float>(myData.currentPhase));
		}
		break;
	}
	case Phases::BuildUp:
	{
		myData.buildUpTimer -= aFixedTime;
		if (myData.buildUpTimer < 0.f/*myData.playerStress >= DirectorData::STRESS_PEAK_THRESHOLD*/)
		{
			myData.buildUpTimer = DirectorStaticData::BUILD_UP_TIMER_RESET;
			myData.currentPhase = Phases::Peak;
			myData.hordeTimer = 0.f;
			Locator::GetAudioManager()->SetParameter(FmodId::MainMusic, static_cast<float>(myData.currentPhase));
			EventManager::Get()->Dispatch<GameEvent>(GameEvent::Message::ShowObjectiveText, std::make_any<HUD::ObjectiveData>(HUD::ObjectiveData{ .textToShow = "The algorithm is trying to cancel you."_tgaid, .timer = 3.f }));
		}
		break;
	}
	case Phases::Peak:
	{
		myData.peakTimer += aFixedTime;
		if (myData.playerStress > DirectorStaticData::STRESS_PEAK_THRESHOLD || myData.peakTimer > DirectorStaticData::PEAK_MAX_TIME)
		{
			myData.peakTimer = DirectorStaticData::PEAK_TIMER_RESET;
			myData.currentPhase = Phases::PeakFade;
		}
		break;
	}
	case Phases::PeakFade:
	{
		myData.peakFadeTimer -= aFixedTime;

		if (myData.peakFadeTimer < 0.f && myData.playerStress < DirectorStaticData::STRESS_MIN_THRESHOLD)
		{
			myData.peakFadeTimer = DirectorStaticData::PEAK_FADE_TIMER_RESET;
			myData.currentPhase = Phases::Relax;
			myData.relaxTimer = DirectorStaticData::RELAX_TIMER_RESET;
			Locator::GetAudioManager()->SetParameter(FmodId::MainMusic, static_cast<float>(myData.currentPhase));
		}

		break;
	}
	default:
	{
		break;
	}
	}
}

    

Code showing the switching of phases.

Show Code

void Forge::AIDirector::UpdateHorde(float aFixedTime)
{
	if (myData.currentPhase != Phases::Peak)return;
	myData.hordeTimer -= aFixedTime;
	if (myPossibleHordeSpawnZones.GetCells().empty()) return;

	if (myData.hordeTimer < 0.f && myData.currentPhase == Phases::Peak)
	{
		// if player is stressed next horde will be smaller and vice versa	
		myData.nextHordeMax = static_cast<int>(FMath::Lerp(static_cast<float>(myData.hordeMaxSize), static_cast<float>(myData.hordeMinSize), myData.playerStress));

		myData.hordeTimer = FMath::Lerp(myData.hordeMinInterval, myData.hordeMaxInterval, myData.playerStress);
		const int spawnZone = myPossibleHordeSpawnZones.GetCells().front();
		std::vector<Object*> toSpawn;
		toSpawn.reserve(myData.nextHordeMax);

		for (Object* enemy : myEnemies)
		{
			if (enemy && !enemy->CheckIfActive())
			{
				toSpawn.emplace_back(enemy);
				if (toSpawn.size() == myData.nextHordeMax)
				{
					break;
				}
			}
		}

		const Tga::Vector3f playerPos = myData.player->GetTransform().GetPosition();
		const  Tga::Vector3f spawnPos = GetXZPositionFromIndex(spawnZone);
		std::vector<Tga::Vector3f> path = myNavmesh->PathfindFunneled(spawnPos, playerPos);

		if (!path.empty())
		{
			const Tga::Vector3f toSpawnDir = spawnPos - playerPos;
			const float distance = std::clamp(toSpawnDir.Length(), 200.f, 1000.f);

			Locator::GetAudioManager()->PlayEventSpatialized(FmodId::PushNotification_Multi, playerPos + toSpawnDir.GetNormalized() * distance);
			Locator::GetAudioManager()->PlayEvent(FmodId::Detect_Stinger);

			for (Object* enemy : toSpawn)
			{
				SpawnEnemy(enemy, spawnPos);
				MakeEnemyPathFind(enemy, path);
			}
		}
	}
}


    

Code showing the horde spawning.

Data Driven

This next part is about how I exposed the directors values to our levels designers. I did this by implementing node scripting support used to spawn hordes at story important events, along with decompression zones, and a JSON-interface used to tweak and things such as horde intervals and sizes.

This code shows the creation of a editor window (using Dear ImGui) which the level designers can use to tweak the director. The data is then saved used using the Nlohmann/JSON library.

Show Code

ImGui::DragFloat("Build Up Phase Length", &myData.buildUpPhaseLength, 0.01f, 10.f, 180.f);
ImGui::DragInt("Min Horde Size", &myData.hordeMinSize, 1.f, 1, ENEMY_MAX_SPAWN);
ImGui::DragInt("Max Horde Size", &myData.hordeMaxSize, 1.f, 1, ENEMY_MAX_SPAWN);
ImGui::DragFloat("Min Horde Interval", &myData.hordeMinInterval, 0.01f, 3.f, 60.f);
ImGui::DragFloat("Max Horde Interval", &myData.hordeMaxInterval, 0.01f, 3.f, 60.f);

if (ImGui::SmallButton("Save Director Settings"))
{
	std::string path = "json/AIDirectorSettings.json";
	std::ifstream jsonFileStream(Tga::Settings::ResolveAssetPath(path));
	nlohmann::json jsonInSettings = nlohmann::json::parse(jsonFileStream);
	nlohmann::json jsonOutSettings = jsonInSettings;

	jsonOutSettings["buildUpPhaseLength"] = myData.buildUpPhaseLength;
	jsonOutSettings["hordeMinSize"] = myData.hordeMinSize;
	jsonOutSettings["hordeMaxSize"] = myData.hordeMaxSize;
	jsonOutSettings["hordeMinInterval"] = myData.hordeMinInterval;
	jsonOutSettings["hordeMaxInterval"] = myData.hordeMaxInterval;

	std::ofstream jsonOut(Tga::Settings::ResolveAssetPath(path));
	jsonOut << std::setw(4) << jsonOutSettings;
}
    

Node scripting support

Even though the AI-Director is mainly an autonomous system, I saw a benefit in giving the level designers have the power to override the director, when they wanted to create a special event. The reference game Left 4 Dead has something called Crescendo Events, which is when the survivors trigger an elevator button or are forced to wait for a rescue helicopter. During these events you always want some sort of challenge and you can’t rely on the director to procedurally sync with this special event in the game’s story.

In contrast if the player enters a safe room where she/he is supposed to recuperate, the designers need to have control over that too.

To achieve this I created a node scripting interface for the levels designers, with which they could override the director’s behavior, as well as different assets they could place in the game world that would talk with the director. I call these assets crescendo points and decompress zones.

Video game scene set in an urban alley at night with a handgun in the foreground, trash bags, a fence with purple rectangular objects, and a character lying on the ground. Text on screen says, 'Here they come...'

The picture above shows a crescendo point placed on the map by the levels designers. When loading the levels the crescendo points’ position and IDs are stored by the director. The crescendo points can later be accessed by the script nodes to spawn enemies or teleport the player etc., for cool scripted events.

The video above shows an event scripted by the level designer Albin Vikström using my scripting interface, which then communicates with the AI-Director.

A visual flowchart of a game or simulation scripting interface using nodes and connections, including entities like Crescendo Point and player, with controls for spawning mobs, timing, and sound alarms.

The picture above shows a script implemented by a level designer, using the node interface I set up. When the player interacts with a scripted object, it sounds an alarm. The director then spawns 5 enemies at a crescendo point pathfinding to the player, as well as three enemies from the closest spawn point calculated by the director.

Abandoned apartment with a cluttered table, scattered food and trash, and bullet holes on the floor, viewed from a first-person shooter game.

This gif shows a decompress zone highlighted with green color. The decompress zones have a simple script, easily implemented by the level designers. When the player enters a decompress zone, the director is put on pause, spawning no hordes attacking the player. Thus level designers can easily create safe houses for the player to heal and reload.

Event Handling & Commands

Part of what makes the node scripting system work well is the use of two well documented design patterns. Namely the publisher/subscriber pattern and the command pattern.

For example, when the player enters a decompress-zone a message is sent via our engine’s message system. The director, being a subscriber/observer to this messages uses it to create a command, temporarily altering the director’s own logic.

This provides much opportunity to remix the behavior of the AI-Director. Opening up for a fun collaboration between me as programmer and the level designers.

A simple script telling the director to let the player relax on collision, as well as ramping on the action when the player leaves the safe zone.

Flowchart diagram with nodes labeled 'Enter', 'Exit', and 'Decompress or Recompose', showing connections for decompressing, relaxing, and recomposing actions.

Code showing how the node sends a message to the director

Show Code

ScriptNodeResult Forge::DecompressStateNode::Execute(Tga::ScriptExecutionContext& context, Tga::ScriptPinId aScriptPinId) const
{
	if (aScriptPinId == myDecompressPin)
	{
		EventManager::Get()->Dispatch<GameEvent>(GameEvent::Message::DecompressAction);
	}
	else if (aScriptPinId == myRecommenceActionPin)
	{
		EventManager::Get()->Dispatch<GameEvent>(GameEvent::Message::RecommenceAction);
	}

	context.TriggerOutputPin(myOutputPin);

	return Tga::ScriptNodeResult::Finished;
}
    

Code showing the director receiving and handling these messages

Show Code

Forge::AIDirector::OnEvent(const GameEvent& e)
{
	switch (e.message)
	{
        // Look Here! The Director creates a command to temporaliry alter gameplay
	case GameEvent::Message::DecompressAction:
	{
		myCommands.emplace_back(std::make_shared<DecompressCommand>());
		break;
	}
	case GameEvent::Message::RecommenceAction:
	{
		for (std::shared_ptr<DirectorCommand> const& command : myCommands)
		{
			if (DecompressCommand* decompress = dynamic_cast<DecompressCommand*>(command.get()))
			{
				decompress->SetIsDone(true);
			}
		}
		break;
	}
	// ... the director handles many more messages / events not shown in this snippet ... 
	
	default:
	{
		break;
	}
	}
}
    

Code showing the logic in a decompress command and the director handling them

Show Code

class DirectorCommand
{
public:
	virtual ~DirectorCommand() = default;
	virtual void Update(float, AIDirector*) {}
	virtual bool CheckIsDone() const { return myIsDone; }
	virtual void SetIsDone(bool aIsDone) { myIsDone = aIsDone; }
protected:
	bool myIsDone = false;
};

class DecompressCommand :public DirectorCommand
{
	void Update(float fixedTime, AIDirector*) override;
};

void Forge::DecompressCommand::Update(float, AIDirector* aAiDirector)
{
	if (aAiDirector)
	{
		auto& data = aAiDirector->myData;
		if (!myIsDone)
		{
#ifndef _RETAIL
			std::cout << "player is decompressing\n";
#endif
			data.relaxTimer   = DirectorData::RELAX_TIMER_RESET;
			data.currentPhase = Phases::Relax;
			Locator::GetAudioManager()->SetParameter(FmodId::MainMusic, static_cast<float>(data.currentPhase));
		}
		else
		{
#ifndef _RETAIL
			std::cout << "player entered battle again\n";
#endif
			data.relaxTimer   = DirectorData::RELAX_TIMER_RESET;
			data.currentPhase = Phases::BuildUp;
			Locator::GetAudioManager()->SetParameter(FmodId::MainMusic, static_cast<float>(data.currentPhase));
		}

	}
}
void Forge::AIDirector::HandleCommands(float aFixedTime)
{
	if (!myCommands.empty())
	{
		for (int i = static_cast<int>(myCommands.size() - 1); i >= 0; --i)
		{
			std::shared_ptr<DirectorCommand> command = myCommands[i];

			command->Update(aFixedTime, this);
			if (command->CheckIsDone())
			{
				myCommands[i] = myCommands.back();
				myCommands.pop_back();
			}
		}
	}
}

    

Other Debug Functions

Using Dear ImGui I created the whole mini-map visualization for the director. It came to be useful not only for me when debugging the director, but also for levels designers as you could see where the enemies walked of the map, making us aware where colliders were missing or where the world had holes.

I also created an enemy debug window which let you choose any enemy in the game, inspect it, tweak it’s animation speed and stats such a run speed. I also made sure to draw colliders on the important parts of the enemies, further visualizing their behavior.

This was helpful for both me as the projects AI-programmer and for the artists/animators.

Quick demo of the enemy debug window.

Challenges & End result

As with many AI-systems I’d say that the biggest challenge has been tweaking the timer variables, the player stress sensitivity and find the right amount of enemies to spawn at any given time. These are thing I will continue to polish on.

My only optimization issue was calculating the horde spawn zones. This had a certain cost since I had to sort the spawn zones in a complex way, including using the A*-algorithm a lot. I solved this by excluding as many spawn zones as possible before doing the more complex sorting. I also time sliced the calculation, so that it only took take place a few times every second, instead of each frame.

In the end I’m really proud of the system that I built. Not only did I get a chance to deep dive into one of my favorite games, for references. It also improved our own game making it fun and less predictable!

Since our game only had three levels, the dynamic enemy spawning kept the game interesting and challenging even if you replayed it. The dynamic music switching and the adaption to the player stress level added to the gameplay and general feel of the game.

I would also argue that the level designers appreciated to have their levels filled with what seemed to be hundreds of enemies, without having to hand place them. While at same time having the ability override the AI Director through scripting, when they wanted to create special events.

I’m also sure that my programmer colleagues appreciated the efficient de-spawning and spawning of enemies. This freed up much memory use and made sure the CPU didn’t bottleneck, leaving more room for heavy processes such as physics and rendering.

Thank you for reading! <3

UI-art by Alex Arabi