Overview
The Taunt ability forces enemies to redirect their focus toward the caster instead of their current target. In this case, enemies switch their focus to Death, who has more health than Lýriel, for a short period of time while Lýriel can focus on attacking safely.
This behaviour is built using three main pieces:
- Ability casting script
- Enemy detection & aggro system
Each part has a clear responsibility:
- Ability script → triggers the taunt effect
- Aggro system → updates enemy targeting
The core idea
Instead of embedding targeting logic directly inside the ability, the system reuses the existing aggro infrastructure to minimise duplicated logic and keep everything more centralised.
When Taunt is cast:
- Affected enemies are detected
- Their current target is overridden
- They switch focus to the caster (Death) instead of the other character (Lýriel)
- After a set duration, enemies return to targeting the closest character
- The ability cooldown begins
This keeps the system modular and avoids duplicating logic.
Step 1 — Ability casting script
A dedicated script, DeathTaunt, was created to handle the casting of Taunt.
Its responsibilities are:
- Detect enemies hit by the ability
- Call the aggro system for each affected enemy
- Store the ability stats
This script acts only as a trigger layer and does not manage targeting directly.
Inside this script, multiple classes handle different responsibilities. Some classes check the aiming direction of the ability, others verify whether enemies are inside the designated area and whether the Taunt can be applied, while others generate the detection areas themselves.
Here is a sneak peek of one of the methods responsible for checking whether Taunt can be applied:
void DeathTaunt::applyTauntToEnemiesInCone(const Vector3& ownerForward) const
{
Transform* ownerTransform = GameObjectAPI::getTransform(m_owner);
if (ownerTransform == nullptr)
{
return;
}
const Vector3 ownerPosition = TransformAPI::getPosition(ownerTransform);
const std::vector<GameObject*> enemies = SceneAPI::findAllGameObjectsByTag(Tag::ENEMY);
Debug::log("[DeathTaunt] Enemies with Tag::ENEMY found: %d", (int)enemies.size());
int taunted = 0;
for (GameObject* enemy : enemies)
{
if (!isEnemyInsideTauntCone(enemy, ownerPosition, ownerForward))
{
Debug::log("[DeathTaunt] Enemy '%s' outside cone.", GameObjectAPI::getName(enemy));
continue;
}
EnemyDetectionAggro* enemyAggro = GameObjectAPI::findScript<EnemyDetectionAggro>(enemy);
if (enemyAggro == nullptr)
{
Debug::log("[DeathTaunt] Enemy '%s' in cone but no EnemyDetectionAggro.", GameObjectAPI::getName(enemy));
continue;
}
enemyAggro->applyTaunt(ownerTransform, m_TauntDurationSeconds);
Debug::log("[DeathTaunt] Taunt applied to '%s' for %.1fs.", GameObjectAPI::getName(enemy), m_TauntDurationSeconds);
}
Step 2 — Integrating with EnemyDetectionAggro
To apply the effect, the casting script calls the existing EnemyDetectionAggro system, which was originally implemented by a teammate.
However, the original system did not support externally forcing a target, so support for overriding targets had to be added.
Required changes:
- Add a method to override the current target
- Allow external systems, such as abilities, to request aggro changes
The EnemyDetectionAggro script, responsible for enemy interactions with player characters, required an additional check to determine whether an enemy was currently taunted. This was handled using a simple isTaunted boolean.
If the enemy was taunted and not downed, the target switched to Death and the taunt timer began. Otherwise, no changes were applied.
Once the taunt duration ended, the taunt state was cleared and enemy detection returned to its original behaviour: selecting the closest detected character and attacking them.
Here are two important methods involved in applying and clearing the Taunt effect:
void EnemyDetectionAggro::applyTaunt(Transform* playerTransform, float durationSeconds)
{
if (playerTransform == nullptr || durationSeconds <= 0.0f)
{
return;
}
m_tauntTargetTransform = playerTransform;
m_tauntTimer = durationSeconds;
m_currentTargetLockTimer = 0.0f;
enterAggro(playerTransform);
AggroEntry* entry = getAggroEntry(playerTransform);
if (entry != nullptr)
{
entry->lastAttackTime = m_currentTime;
}
}
void EnemyDetectionAggro::clearTaunt(Transform* playerTransform)
{
if (playerTransform != nullptr && playerTransform != m_tauntTargetTransform)
{
return;
}
const bool wasCurrentTarget = (m_currentTargetTransform == m_tauntTargetTransform);
m_tauntTargetTransform = nullptr;
m_tauntTimer = 0.0f;
if (!wasCurrentTarget)
{
return;
}
Transform* fallbackTarget = selectClosestDetectedPlayer();
if (fallbackTarget != nullptr)
{
m_currentTargetTransform = fallbackTarget;
m_lastKnownTargetPosition = TransformAPI::getPosition(fallbackTarget);
m_canSeeTarget = true;
m_isAggro = true;
}
else
{
resetAggro();
}
}
This enables abilities to interact cleanly with enemy AI without breaking encapsulation.
Step 3 — Target override behaviour
Once triggered:
- Enemies replace their current target
- Their behaviour naturally follows the existing state logic (chase, attack, etc.)
- No additional AI changes are required
This works because the AI states already query the controller for the current target. By changing the target, enemy behaviour is automatically redirected.
An important rule of this implementation is that the ability itself does not directly control enemy behaviour.
Instead, it only modifies shared data (the target), allowing:
- The *EnemyController *to remain the single source of truth
- State scripts to continue working without modification
Final result
The Taunt ability integrates seamlessly into the system by:
- Reusing the existing aggro logic
- Avoiding duplicated behaviour code
- Keeping responsibilities clearly separated
This approach ensures that future abilities affecting enemy targeting can follow the same pattern with minimal additional changes.
