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).
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.
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.
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.
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.
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