Curse of Immortality - Enemy AI and Combat Systems

University

Hochschule der Medien

Game

Curse of Immortality

Date

2023

Duration

3 Months

Team Size

5

Main Roles

Game Programmer, AI Programmer

Curse of Immortality is a roguelite dungeon crawler built in Unreal where I focused heavily on gameplay programming in C++. My work centered on enemies, combat readability, and encounter flow. It was my first bigger project in Unreal Engine and, in general, one of the first games I made.

Together with four other students, inspired by Hades, we started designing and developing Curse of Immortality. For me, it was definitely one of the key experiences that shaped my career choice and made me want to become a programmer in the games industry.

Important Links

Check out the repository: Source code
Most of my code participation: AI source folder
Play the latest build: GitHub release
Watch the walkthrough: Video walkthrough

Gameplay Programming Focus

My goal in this project was to learn C++ in combination with Unreal. I had already done smaller projects with Blueprints, but I wanted to build a new skill set. I decided to make AI my main responsibility because I wanted to help create a combat experience with a lot of variety.

The main gameplay programming areas I worked on were:

  • gameplay logic implemented in C++
  • enemy state machines and enemy-specific combat logic
  • custom A* pathfinding with static and dynamic heat values
  • spawn and start-location behavior so encounters opened cleanly
  • arena trap integration through shared C++ systems

Shared C++ Systems Behind the Enemies

State machines as the backbone

Most enemies in Curse of Immortality follow the same structural idea, a state machine owns the high-level behavior, while each state handles a focused piece of logic such as idling, path-following, attacking, recovering, or entering the arena. I liked this approach because it kept the behaviors modular while still making room for enemy-specific rules.

Wake-up logic and encounter pacing

One detail that matters a lot in arena games is how enemies wake up. Some enemies would walk through the gates of the arena, while others spawned outside the player's view. Enemies could respond both to proximity and to being attacked from range, which made fights feel more reactive and less scripted.

void UDeprivedIdle::OnStateUpdate(float DeltaTime)
{
	Super::OnStateUpdate(DeltaTime);

	const FVector PlayerLocation = Player->GetActorLocation();

	SelfRef->CurrentJumpAttackCoolDown = FMath::RandRange(0.f, SelfRef->JumpAttackCoolDown / 2);
	SelfRef->CurrentFrenziedAttackCoolDown = FMath::RandRange(0.f, SelfRef->FrenziedAttackCoolDown / 2);

	if (FVector::Dist(PlayerLocation, SelfRef->GetActorLocation()) < SelfRef->DistRunning)
	{
		Controller->Transition(Controller->Running, Controller);
	}
	if (Cast<APlayerCharacter>(SelfRef->LastDamagingActor))
	{
		Controller->Transition(Controller->Running, Controller);
	}
}

Heat-based pathfinding

I also spent quite a bit of time on pathfinding. Together with one of my fellow students, we implemented an A* algorithm, and I extended it with static and dynamic heat so enemies could treat some routes as less desirable without treating them as fully blocked. That helped reduce larger enemy clumps, and bigger enemies could influence the grid with more heat.

bool APathfindingGrid::GetPath(
	int StartX,
	int StartY,
	int EndX,
	int EndY,
	TArray<FPfNode*>& Path,
	bool Verbose)
{
	TArray<FPfNode*> OpenList, ClosedList;
	FPfNode* StartNode = &GetValue(StartX, StartY);
	const FPfNode* EndNode = &GetValue(EndX, EndY);

	StartNode->G = 0;
	StartNode->H = CalculateDistance(StartX, StartY, EndX, EndY);
	StartNode->S = StartNode->H;
	OpenList.Add(StartNode);

	while (OpenList.Num() > 0)
	{
		FPfNode* Current = GetLowestCostNode(OpenList);
		if (Current == EndNode)
		{
			CalculatePath(Current, Path, Verbose);
			return true;
		}

		OpenList.Remove(Current);
		ClosedList.Add(Current);

		TArray<FPfNode*> Neighbors = GetNeighbors(Current->X, Current->Y);
		for (FPfNode* Neighbor : Neighbors)
		{
			if (ClosedList.Contains(Neighbor) || !Neighbor->IsWalkable)
			{
				continue;
			}

			const int TempGCost =
				Current->G +
				CalculateDistance(Current->X, Current->Y, Neighbor->X, Neighbor->Y) +
				Neighbor->StaticHeat +
				Neighbor->DynamicHeat;

			if (TempGCost < Neighbor->G)
			{
				Neighbor->G = TempGCost;
				Neighbor->H = CalculateDistance(
					Neighbor->X,
					Neighbor->Y,
					EndNode->X,
					EndNode->Y);
				Neighbor->S = Neighbor->G + Neighbor->H;
				Neighbor->CameFrom = Current;

				if (!OpenList.Contains(Neighbor))
				{
					OpenList.Add(Neighbor);
				}
			}
		}
	}

	return false;
}

The important detail is the extra cost added through static heat and dynamic heat. Static heat came from the environment objects like traps and pillars, while dynamic heat came from enemies that were walking around in the arena. The path was still an A* path, but now the current arena state was part of the pathfinding heuristic.

void APathfindingGrid::GenerateDynamicHeatMap(float DeltaTime)
{
	if (Delay <= 0)
	{
		for (int x = 0; x < Width; ++x)
		{
			for (int y = 0; y < Height; ++y)
			{
				GetValue(x, y).DynamicHeat = 0.f;
			}
		}

		TArray Enemies(FPersistentWorldManager::GetEnemies());
		for (ABaseCharacter* Enemy : Enemies)
		{
			int X, Y;
			if (Cast<AMolochPawn>(Enemy))
			{
				if (GetCoordinatesFromWorldPosition(Enemy->GetActorLocation(), X, Y))
				{
					GetValue(X, Y).DynamicHeat += 25.f;
					for (FPfNode* Neighbor : GetNeighbors(X, Y))
					{
						Neighbor->DynamicHeat += 25.f;
					}
				}
			}
			else if (GetCoordinatesFromWorldPosition(Enemy->GetActorLocation(), X, Y))
			{
				GetValue(X, Y).DynamicHeat += 10.f;
				for (FPfNode* Neighbor : GetNeighbors(X, Y))
				{
					Neighbor->DynamicHeat += 5.f;
				}
			}
		}

		Delay = 0.5f;
	}

	Delay -= DeltaTime;
}

The dynamic heat map was recalculated over time, which let the AI react to moving obstacles instead of relying on a baked solution.

Image

Enemy Implementations

Deprived

Deprived was the first enemy type I implemented, and we wanted to have two versions of it. A normal version would spawn in larger numbers with a very simple attack pattern, while an alpha version had three unique attacks, more health, and more damage, but only a few would spawn at a time.

Normal version of the deprived enemy type
Alpha version of the deprived enemy type
void UDeprivedRunning::OnStateUpdate(float DeltaTime)
{
	const FVector PlayerLocation = Player->GetActorLocation();

	if (Controller->CheckLineOfSight(PlayerLocation) &&
		!(FVector::Dist(SelfRef->GetActorLocation(), PlayerLocation) <= 100.f))
	{
		if (PathfindingTimer <= 0)
		{
			Controller->FindPathToPlayer(Path);
			PathIndex = 0;
			PathfindingTimer = 1.f;
		}
		if (!Path.IsEmpty() && Controller->FollowPath(Path, DeltaTime, PathIndex))
		{
			PathIndex++;
		}
	}
	else
	{
		Controller->MoveToTarget(PlayerLocation, SelfRef->Stats[Movespeed], DeltaTime);

		if (!SelfRef->WeakDeprived)
		{
			if (FVector::Dist(PlayerLocation, SelfRef->GetActorLocation()) < SelfRef->DistFrenziedAttack &&
				SelfRef->CurrentFrenziedAttackCoolDown <= 0.f)
			{
				SelfRef->CurrentFrenziedAttackCoolDown = SelfRef->FrenziedAttackCoolDown;
				Controller->Transition(Controller->FrenziedAttack, Controller);
			}
			else if (FVector::Dist(PlayerLocation, SelfRef->GetActorLocation()) < SelfRef->DistJumpAttack &&
				SelfRef->CurrentJumpAttackCoolDown <= 0.f)
			{
				SelfRef->CurrentJumpAttackCoolDown = SelfRef->JumpAttackCoolDown;
				Controller->Transition(Controller->JumpAttack, Controller);
			}
		}
	}
}

If the Deprived has line of sight or gets hit by the player, it searches for a path. Once it reaches direct engagement range, the state decides between attacks based on distance and cooldown state.

Inu

The Inu is probably the most annoying enemy type in the game. The design idea was to have a small ranged unit that can create pressure quickly but is easy to kill. Once the player is detected, the Inu runs into attack range and starts shooting small needles at a high rate. If the player gets too close, the Inu tries to run away.

The Inu was special because it could not only be spawned by the arena, but could also emerge from the puddles spawned by the end boss. In larger groups, Inus can especially become a real threat and quickly kill the player.

void UInuStateMachine::TickComponent(float DeltaTime, ELevelTick TickType,
	FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	if (!Player)
	{
		Player = FPersistentWorldManager::PlayerCharacter;

		if (SelfRef->SpawnedByMaw)
		{
			SelfRef->GetRootComponent()->SetVisibility(false, true);
			CurrentState = Emerge;
		}
		else
		{
			CurrentState = FindStartLocation;
		}
		CurrentState->OnStateEnter(this);
	}
}

void UInuEmergeState::OnStateUpdate(float DeltaTime)
{
	if (SelfRef->AnimationStart)
	{
		SelfRef->GetRootComponent()->SetVisibility(true, true);
	}
	if (SelfRef->AnimationEnd)
	{
		Controller->Transition(Controller->Running, Controller);
	}
}

Storm Caller

Storm Caller was designed more like an arena pressure tool than a direct enemy. The code reflects that: it uses a much simpler state setup than the melee enemies. The only two states the Storm Caller needs are idle and attack. While in its attack state, it spawns lightning bolts around the player and then returns to idle to recharge.

The Storm Caller randomly selects attack locations in an area around the player. With help from the pathfinding grid, it can check whether the selected location is occupied and only spawn lightning bolts in areas where the player could actually walk.

bool AStormCallerPawn::GetSpawnPosition(FVector& Position, FRotator& Rotation)
{
	FVector PlayerPosition = FPersistentWorldManager::PlayerCharacter->GetActorLocation();
	for (int i = 0; i < 100; ++i)
	{
		const auto Node = FPersistentWorldManager::PathfindingGrid->GetRandomNodeInNavMesh();
		if (!Node->IsWalkable || Node->GetCombinedHeat() > 5 || Node->SpawnArea)
			continue;

		FVector SpawnPosition = FVector::ZeroVector;
		if (FPersistentWorldManager::PathfindingGrid->GetWorldPositionFromCoordinates(Node->X, Node->Y, SpawnPosition))
		{
			if (FVector::Distance(PlayerPosition, SpawnPosition) > 2000.0f)
			{
				SetActorLocation(SpawnPosition);
				return true;
			}
		}
	}
	return false;
}

void UStormCallerAttack::OnStateUpdate(float DeltaTime)
{
	if (WaitForAnimation && SelfRef->CurrentAttackCoolDown <= 0.f)
	{
		SelfRef->StormCast->StartAbility(SelfRef->AbilitySpecification, SelfRef);
		SelfRef->CurrentAttackCoolDown = SelfRef->AttackCoolDown;
	}
}

Moloch

Moloch is the tank unit spawned in the arena and has a variety of attacks. It can stomp with its front legs, start a charging attack that the player can bait into obstacles, use a kick with its back legs, and finish with a headbutt to damage the player.

With multiple attacks and therefore more states, Moloch was definitely more challenging to implement, especially because we decided pretty late that it should be in the game and I built the logic in a day.

void UMolochWalking::OnStateUpdate(float DeltaTime)
{
	FVector HeadLocation = SelfRef->HeadLocation->GetComponentLocation();
	FVector MidLocation = SelfRef->GetActorLocation();
	FVector BackLocation = SelfRef->BackLocation->GetComponentLocation();
	FVector PlayerLocation = Player->GetActorLocation();

	FVector Dir = PlayerLocation - HeadLocation;
	FVector Forward = SelfRef->GetActorForwardVector();
	Dir.Z = 0;
	Forward.Z = 0;

	if (Controller->CheckLineOfSight(PlayerLocation))
	{
		if (PathfindingTimer <= 0.f)
		{
			Path.Empty();
			Controller->FindPathToPlayer(Path);
			PathfindingTimer = 0.5f;
			PathIndex = 0;
		}
	}
	else
	{
		if (FVector::Dist(PlayerLocation, HeadLocation) >= SelfRef->ChargeRange)
		{
			if (SelfRef->CurrentChargeAttackCoolDown <= 0.f)
			{
				Controller->Transition(Controller->PrepareCharge, Controller);
			}
		}
		else
		{
			if (Controller->CalculateAngleBetweenVectors(Dir, Forward) <= 40.f &&
				FVector::Dist(PlayerLocation, HeadLocation) <= SelfRef->AttackRange)
			{
				Controller->Transition(Controller->NormalAttack, Controller);
			}
			else if (Controller->CalculateAngleBetweenVectors(Dir, Forward) >= 160.f &&
				FVector::Dist(PlayerLocation, BackLocation) <= SelfRef->AttackRange)
			{
				Controller->Transition(Controller->Kick, Controller);
			}
			else if (FVector::Dist(PlayerLocation, MidLocation) <= SelfRef->AttackRange)
			{
				Controller->Transition(Controller->Stomping, Controller);
			}
		}
	}
}

Maw of Sothros

Maw of Sothros pushed the same ideas even further. Instead of a single attack loop, the boss needed a broader move set and a way to vary that move set without becoming completely random. I handled that with weighted attack groups for ranged, melee, and back-side situations. After using one move, the weights are adjusted so the boss does not spam the same option over and over again.

The Maw of Sothros has a total of six different attack moves and can also damage the player with the dark smoke surrounding it. The first attack is a simple ground slam where the Maw uses its fist to punch the player. Another melee attack is the charge, where the Maw chains together a combo while trying to close the distance. My personal favorite is the eye laser which, as the name suggests, creates a huge beam to hit the player. Another special move is the puddles the Maw can spit out, which damage the player on contact and spawn Inus to put pressure on the player. The last attack is a tail sweep that the Maw performs if the player starts trying to get behind it.

UMawOfSothrosStateMachine::UMawOfSothrosStateMachine()
{
	RangedAttackTypes.Add(FAttackType(VomitState, 75));
	RangedAttackTypes.Add(FAttackType(ChargeAttackState, 50));
	RangedAttackTypes.Add(FAttackType(LaserState, 100));

	MeleeAttackTypes.Add(FAttackType(GroundSlamState, 100));
	MeleeAttackTypes.Add(FAttackType(ChargeAttackState, 50));
	MeleeAttackTypes.Add(FAttackType(VomitState, 25));

	BackAttackTypes.Add(FAttackType(TailSweepState, 100));
}

void UMawOfSothrosIdle::AttackRandomizer(TArray<FAttackType>& Attacks) const
{
	int WeightSum = 0;
	for (int i = 0; i < Attacks.Num(); ++i)
	{
		WeightSum += Attacks[i].CurrentWeight;
	}

	int Rand = FMath::RandRange(0, WeightSum);
	for (int i = 0; i < Attacks.Num(); ++i)
	{
		if (Attacks[i].CurrentWeight >= Rand)
		{
			for (int f = 0; f < Attacks.Num(); ++f)
			{
				if (Attacks[i].Type != Attacks[f].Type)
					Attacks[f].ResetWeight();
			}

			Attacks[i].CurrentWeight /= 2;
			Controller->Transition(Controller->Laser, Controller);
			return;
		}
		Rand -= Attacks[i].CurrentWeight;
	}
}

I tried to create an enjoyable boss fight by giving the Maw a varied set of attacks while still leaving room for the player to fight back. Especially after the charge attack and ground slam, I left open windows where the player could go on the offensive, although having a strong ability setup definitely helped as well.

Implementation of Traps

Another system I worked on was trap integration. Traps were not supposed to behave like isolated level gimmicks. They needed to respond to arena rules, support different priorities, and share the same combat language as the rest of the game. I solved that by keeping the trap manager responsible for broadcasting state changes while individual trap components reacted locally.

Based on the current wave in the arena, traps would be randomly activated. There are four kinds of traps: ground spikes, rotating saws, blade pillars, and arrow turrets. Traps can also damage enemies, so using them to your advantage can definitely become a valid tactic. On every wave, the game would randomly decide whether to activate a new trap or upgrade an existing one with more damage or a higher attack speed.

void UTrapManagerCopmonent::UpgradeTrapsOfType(TEnumAsByte<ETrapTypes> Type)
{
	UpgradeTraptype.Broadcast(Type, GetLvl(Type));
}

void UTrapComponent::CheckActivation(TEnumAsByte<ETrapTypes> OtherTrapType, int prio)
{
	if (OtherTrapType == TrapType && prio >= Prio || OtherTrapType == ETrapTypes::All)
	{
		TrapIsActive = true;
	}
}

void UTrapComponent::CheckDeactivation(TEnumAsByte<ETrapTypes> OtherTrapType, int prio)
{
	if (OtherTrapType == TrapType && prio <= Prio || OtherTrapType == ETrapTypes::All)
	{
		TrapIsActive = false;
	}
}

That delegate-based structure kept the arena code cleaner and made it much easier to mix trap types without hardcoding every trap instance into the same manager. From a gameplay programming perspective, this was valuable because it let traps scale with encounter design instead of fighting against it.

Overall, Curse of Immortality was a very strong gameplay programming project for me because it sits at the intersection of AI, combat, encounter design, and C++ systems design. It taught me that good enemy behavior is rarely just about one state machine. It comes from a lot of smaller systems agreeing on what a fight should feel like.