Duskborn - Gameplay Programming and Technical Leadership

University

Hochschule der Medien

Game

Duskborn

Date

2023

Duration

4 Months

Team Size

18

Main Roles

Game Programmer, Tech Lead

Duskborn is a third-person shooter roguelite. Using a variety of movement options, you explore the world and upgrade your character with items until you are strong enough to defeat the final boss. If you want to try it yourself, you can download it for free on Steam.

The game was created as part of a course at HdM, where I studied during bachelor's degree. The goal of the course was to create a production experience as close to real studio work as possible. A team of 18 people developed the game across the departments Game Design, Engineering, Art, Sound, and QA. My main role was Head of Engineering, but I also worked as a gameplay programmer with a strong focus on the player character.

This project was probably the most enjoyable group work of my studies, but also one of the most demanding. In just three months, we managed to create a game the whole team could be proud of. With a team of that size, communication became one of the most important parts of the process. I learned a lot about communicating ideas across different departments and made a conscious effort to help build a shared vocabulary so we could understand each other better. Development of Duskborn continued after the semester, but I was unable to stay involved because I had to focus on my bachelor's thesis.

Important Links

Play on Steam: Duskborn
Check out the Repository: Code samples
Play the release I worked on: GitHub release
Watch the walkthrough: Video walkthrough

Hands-On Tech Lead Experience

One of the things that made Duskborn special was how strongly it tried to mirror a real studio workflow. The team was split into departments, milestone goals were discussed between the leads, and tasks had to be distributed across the team.

Because I already had experience with Unreal and was used to working in a team, I wanted to take on the tech lead role for this project. Of course, this was more of a first hands-on tech lead experience, and I know that the responsibilities in a professional studio are much bigger, but it still gave me a valuable first impression of what that role involves.

In total, we were four programmers, including me. At the beginning, my main goal was to understand the strengths of my team and decide which technical tasks each person should focus on. I also set up Perforce as our version control, created the Unreal project, and wrote documentation to help everyone get those tools running. On top of that, I organized a weekly engineering meeting where we could discuss progress, talk about next steps, and share the latest updates from the other departments.

I also worked closely with the Game Design lead. That was important to me because I wanted to build a software architecture that supported the design goals of the game. One of the core mechanics was a craftable item system that allowed the player to create unique effects. Because of that, I designed a system that ran in C++ while exposing the implementation of the actual effects to Blueprints, so the game designers could script those effects themselves. For enemy behavior, I encouraged the team to use the state machine pattern and showed them how to implement it. I also shared learning resources to give everyone an easier start with Unreal Engine.

Once the project was properly underway, my day-to-day work included creating tasks in Jira, distributing them within the team, attending meetings with the other department leads to define milestone goals, and helping out whenever someone ran into technical problems. I especially tried to keep an open ear for non-technical team members as well, because making their workflow easier was just as important for the success of the project.

This experience showed me that I can definitely see myself growing into a tech lead role in the future. While I really enjoy programming, I also feel confident when it comes to communication, finding solutions, and taking responsibility for a project.

Building the player controller

State-driven traversal and combat

I implemented two state machines running in parallel to handle character logic. The first was for movement and the second one for combat. I did this because our weapons of the character are prostheses which can be swapped and I wanted to use the state pattern to attach a unique state to every prosthesis for their combat skills. This way a prosthesis swap would be very simple as it's just a state transition

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

	check(CurrentState);
	CurrentState->Update(DeltaTime);
}

void UStateMachine::StateTransition(UState* NewState)
{
	if (!NewState)
	{
		UE_LOG(LogTemp, Warning, TEXT("Invalid State for transition"))
		return;
	}
	check(CurrentState);

	CurrentState->End();
	CurrentState = NewState;
	CurrentState->Start();
}

The player in Duskborn can sprint, dash, grapple, hover with a jetpack, attack, switch prostheses, and chain those actions together in different combinations. That quickly becomes messy if every input directly checks every other possible condition. I handled that by routing input through state objects. The current movement state decides what an input means, and the combat state can run alongside it when the player is attacking.

bool UPlayerGroundMovement::HandleAction(const EInputs Input, const EInputEvent Event)
{
	Super::HandleAction(Input, Event);
	if (!ValidateState()) { return false; }

	switch (Input)
	{
	case EInputs::ACTION:
		if (Event == IE_Pressed && PLAYER->ActiveProsthesis->CanUsePrimary && !PLAYER->BlockPrimaryAttack)
		{
			STATE_MACHINE->CombatStateTransition(
				PLAYER->ActiveProsthesis->GetPrimaryAttackState());
			return true;
		}
		break;
	case EInputs::JUMP:
		if (Event == IE_Pressed)
		{
			if (PLAYER->GetCurrentJetPackFuel() > 0.f && PLAYER->GetCharacterMovement()->IsFalling())
			{
				STATE_MACHINE->StateTransition(STATE_MACHINE->JetPackState);
				return true;
			}

			if (PLAYER->CanJump())
			{
				PLAYER->Jump();
				return true;
			}
		}
		break;
	case EInputs::SECONDARY_ACTION:
		if (Event == IE_Pressed && PLAYER->FindGrapplingPoint())
		{
			STATE_MACHINE->StateTransition(STATE_MACHINE->GrapplingState);
			return true;
		}
		break;
	case EInputs::SPRINT:
		if (Event == IE_Pressed && PLAYER->GetHorizontalVelocity().Length() > 0.f)
		{
			STATE_MACHINE->DisableCombatState();
			STATE_MACHINE->StateTransition(STATE_MACHINE->SprintState);
			return true;
		}
		break;
	case EInputs::DASH:
		if (Event == IE_Pressed && PLAYER->CanDash)
		{
			STATE_MACHINE->StateTransition(STATE_MACHINE->DashState);
			return true;
		}
		break;
	case EInputs::ABILITY_SWAP:
		if (Event == IE_Pressed && PLAYER->ProsthesisSwap())
		{
			return true;
		}
		break;
	default:
		break;
	}

	return false;
}

This is a small excerpt from the ground movement state, but it shows the key idea well. The same input can lead to very different outcomes depending on the current state. Jump may become a normal jump or transition into the jetpack state. Secondary action may do nothing or enter grappling. Sprint, dash, and prosthesis swap are all explicit state transitions. That kept the controller readable while still giving the player a lot of freedom.

Grappling Hook Implementation

The grappling hook was one of the mechanics I enjoyed working on the most. We wanted something more advanced than the classic "just pull to target" version. The goal was to let us decide when the grappling hook should pull the player toward the target point and when it should simply allow the player to hang and swing. On button press, the player can then start moving toward the grappling point to build up speed.

To achieve that, I used a separate hook actor with a physics constraint and a player attachment sphere. This made it possible to support swinging, pulling, and even grappling onto moving enemies, while still keeping the movement grounded in physical rules.

One challenge was that the constraint cannot really shrink on its own. While it is possible to reduce the constraint distance, I needed a more dynamic solution for cases where the player is falling downward and grapples the ground below. I solved this by calculating the centripetal force of the current swing. If the downward force of the character becomes stronger than that, the constraint is temporarily broken and only attached again once the player reaches the current maximum rope distance. This led to a much more satisfying result, which can be seen in the video below.

The setup function creates the connection between player and hook, optionally attaches the hook to an enemy component, and resets the physics state so the swing starts with the player's current momentum. Combined with the grappling state logic, this gave us a movement system where rope length, pull direction, dash, and jetpack boost all interact with each other to create a unique traversal system.

void AGrapplingHook::SetupGrapplingHook(
	const FVector& GrapplingLocation,
	const FVector& PlayerLocation,
	const FVector& StartVelocity,
	UPrimitiveComponent* NewGrappledComponent)
{
	if (NewGrappledComponent)
	{
		GrappledComponent = NewGrappledComponent;
		Hook->AttachToComponent(
			GrappledComponent,
			FAttachmentTransformRules::SnapToTargetNotIncludingScale);
	}
	else
	{
		Hook->SetWorldLocation(
			GrapplingLocation,
			false,
			nullptr,
			ETeleportType::ResetPhysics);
	}

	PlayerAttachmentSphere->SetWorldLocation(
		PlayerLocation,
		false,
		nullptr,
		ETeleportType::ResetPhysics);

	PhysicsConstraint->SetWorldLocation(GrapplingLocation);
	PhysicsConstraint->SetConstrainedComponents(
		PlayerAttachmentSphere,
		"None",
		Hook,
		"None");

	PlayerAttachmentSphere->SetSimulatePhysics(false);
	PlayerAttachmentSphere->SetSimulatePhysics(true);
	PlayerAttachmentSphere->SetAllPhysicsLinearVelocity(StartVelocity);
}

The main logic of the grappling hook is implemented inside its own state. There are quite a few edge cases that need to be handled carefully. For example, using the jetpack should immediately break the grappling hook attachment, while actions like dashing can easily create awkward movement if the current rope state is not updated correctly. The state constantly checks the player's movement mode, rope tension, and whether the character is currently pulling toward the hook point, then decides if the player should keep swinging, start pulling, or detach again.

void UPlayerGrappling::Update(const float DeltaTime)
{
	Super::Update(DeltaTime);
	if (!ValidateState()) { return; }
	if (!PLAYER->GetWorld())
	{
		Log::Print("World not found Grappling State");
		return;
	}

	PLAYER->AttachRopeToGrapplingPoint();

	if (PLAYER->GetCharacterMovement()->MovementMode != MOVE_Walking ||
		(PLAYER->GetCharacterMovement()->Velocity.Length() - 1.f) > PLAYER->GetCharacterMovement()->MaxWalkSpeed ||
		PLAYER->IsPulling)
	{
		if (PLAYER->ShortenRope)
		{
			PLAYER->ShortenRopeLength();
		}
		//Calculate the difference between RopeLength and the actual distance between player character and GrapplingPoint
		PLAYER->CalcRopeOffset();

		if (!PLAYER->UsingPhysicsHook && !PLAYER->IsPulling)
		{
			//If physics hook is not already or player character is pulling check if the offset is to large
			if (PLAYER->RopeOffset > RopeTolerance) //Tolerance to make sure physics hook is not directly used
			{
				//If the offset is to large it means the player character needs to stop falling and should start swinging with the grappling hook.
				//That's why the player needs to be "attached" to BP_GrapplingHook
				PLAYER->AttachPhysicsHook();
			}
		}
		if (PLAYER->IsPulling)
		{
			//If the player is pulling the player character can be detached from BP_GrapplingHook
			if (PLAYER->UsingPhysicsHook)
				PLAYER->DetachPhysicsHook();

			//Pull towards grappling point
			PLAYER->PullGrapplingHook();
		}
		//If the downforce of the player character is larger than the centripetal force of the character, detach from BP_GrapplingHook
		//This is needed because the constraint component of unreal is more like a rod than a rope and CheckDownForce will generate a more realistic result
		if (PLAYER->RopeOffset < RopeTolerance && PLAYER->UsingPhysicsHook && PLAYER->GrapplingHook->CheckDownForce())
		{
			Log::Print(PLAYER->RopeOffset);
			PLAYER->DetachPhysicsHook();
		}
		if (PLAYER->UsingPhysicsHook)
		{
			//BP_Grappling hook will generate the physical movement of an object on a circular path. To make the player character
			//follow, there velocity is set to the velocity of BP_GrapplingHook AttachmentSphere.
			PLAYER->GetCharacterMovement()->Velocity =
				PLAYER->GrapplingHook->PlayerAttachmentSphere->GetPhysicsLinearVelocity();

			//This Interpolation is used to prevent falling down when the character player is just hanging with the grappling hook.
			const FVector InterpolatedLocation =
				FMath::VInterpTo(PLAYER->GetActorLocation(),
				                 PLAYER->GrapplingHook->PlayerAttachmentSphere->GetComponentLocation(),
				                 DeltaTime, 10);

			FHitResult Sweep;
			//Set player character location to new interpolated value
			PLAYER->SetActorLocation(InterpolatedLocation, true, &Sweep, ETeleportType::TeleportPhysics);

			//If the sweep result is detecting a collision the player character should directly stop following BP_GrapplingHook.
			//This will prevent glitching into walls. Especially when the player character is close to the celling
			if (Sweep.bBlockingHit)
			{
				PLAYER->DetachPhysicsHook();
			}
		}
	}
	else if (PLAYER->UsingPhysicsHook)
	{
		PLAYER->DetachPhysicsHook();
	}

	if (!PLAYER->IsPulling)
	{
		//If the player character is not pulling but the sprint input is still pressed, start pulling
		if (PLAYER->InputManager->CheckIfInputIsPressed(EInputs::SPRINT))
			PLAYER->InitPullGrapplingHook();
	}

	//Check if player character is currently swinging to activate trigger in trigger manager
	if (PLAYER->CheckIfSwinging())
	{
		TRIGGER_MANAGER->ActivateTrigger(ETriggerTypes::WHILE_SWINGING);
	}
	else
	{
		TRIGGER_MANAGER->DeactivateTrigger(ETriggerTypes::WHILE_SWINGING);
	}
}

With the grappling hook in place, the player can move through the level very quickly. Since our world is built more around horizontal exploration, the grappling hook became a great way to move up and down through the map while searching for enemies and upgrades. I personally really enjoyed simply jumping into the abyss and swinging my way through without adding extra pull force. The following video shows exactly that.

Modular prostheses and combat abilities

Duskborn's combat is built around replaceable arm prostheses. Each prosthesis has its own attacks, special ability, animations, cooldowns, and damage behavior. My goal was to make the arms interchangeable. In this prototype, I only implemented the two default prostheses, but the long-term plan was to include character creation where prostheses could be swapped out. At runtime, the player spawns both prostheses, attaches them to the correct sockets, and switches between them through a shared controller flow.

bool APlayerCharacter::CreateProstheses()
{
	FActorSpawnParameters SpawnParams;
	SpawnParams.Owner = this;
	SpawnParams.Instigator = GetInstigator();

	LeftProsthesis = GetWorld()->SpawnActor<AProsthesis>(
		LeftProsthesisClass,
		FVector(0, 0, 0),
		FRotator(0, 0, 0),
		SpawnParams);
	LeftProsthesis->Init(this);
	LeftProsthesis->AttachProsthesis(GetMesh(), "LeftShoulderSocket");

	RightProsthesis = GetWorld()->SpawnActor<AProsthesis>(
		RightProsthesisClass,
		FVector(0, 0, 0),
		FRotator(0, 0, 0),
		SpawnParams);
	RightProsthesis->Init(this);
	RightProsthesis->AttachProsthesis(GetMesh(), "RightShoulderSocket");

	ActiveProsthesis = LeftProsthesis;
	ActiveProsthesis->IsActive = true;
	InactiveProsthesis = RightProsthesis;
	ProsthesisSwapUI.Broadcast();
	return true;
}

bool APlayerCharacter::ProsthesisSwap()
{
	if (!DisableProsthesisSwap)
	{
		STATE_MACHINE->DisableCombatState();
		ActiveProsthesis->IsActive = false;

		if (ActiveProsthesis == LeftProsthesis)
		{
			ActiveProsthesis = RightProsthesis;
			InactiveProsthesis = LeftProsthesis;
		}
		else
		{
			ActiveProsthesis = LeftProsthesis;
			InactiveProsthesis = RightProsthesis;
		}

		ActiveProsthesis->IsActive = true;
		ProsthesisSwapUI.Broadcast();
		return true;
	}

	return false;
}

That foundation made it much easier to support different combat identities. A rifle prosthesis could expose ranged states and cooldown logic, while the sword prosthesis could own combo and skewer behavior, yet both still fit into the same player-side input pipeline. It also gave UI and animation a single place to react whenever the active arm changed.

Melee prostheses featuring a three-hit combo and a dash attack special ability
Ranged prostheses shooting bullets and uses time dilation as a special ability

An upgrade system built for designers

Another major task was the upgrade system. The game combines effect parts and trigger parts into items, which lets players build synergies over the course of a run. I wanted the framework to separate the reusable engineering layer from the actual gameplay content, so that specific effects could later be authored more easily without rewriting the whole system.

The item factory is the assembly step. It creates the runtime item, spawns the corresponding effect actor, initializes it with trigger-specific scaling, and registers the item with the trigger manager. From there, the observer pattern takes over.

UItem* AItemFactory::CreateItem(UEffectPart* EffectPart, UTriggerPart* TriggerPart)
{
	UItem* NewItem = NewObject<UItem>();
	if (EffectPart == nullptr || TriggerPart == nullptr)
	{
		Log::Print("Effect or Trigger null");
		return nullptr;
	}

	AEffect* Effect = nullptr;
	if (TRIGGER_MANAGER)
	{
		FActorSpawnParameters SpawnParams;
		SpawnParams.Owner = PLAYER;

		Effect = GetWorld()->SpawnActor<AEffect>(
			EffectPart->EffectClass,
			FVector::Zero(),
			FRotator::ZeroRotator,
			SpawnParams);

		Effect->Init(
			TRIGGER_MANAGER->FindTrigger(TriggerPart->Type)->EffectScale);
	}

	if (!Effect)
	{
		Log::Print("No effect found");
		return nullptr;
	}

	NewItem->Init(EffectPart, TriggerPart, Effect);
	TRIGGER_MANAGER->AddItem(NewItem);
	PLAYER->AddItem(NewItem);
	return NewItem;
}

Triggers collect subscribers and notify equipped items once a condition is fulfilled. Some fire immediately, others first fill a threshold and then enter an active update loop. That gave us a flexible framework for conditions like damage thresholds, critical hits, or state-based bonuses, while keeping the concrete item logic decoupled from the player controller.

void UTrigger::NotifySubscribers(AActor* EffectInstigator, const FVector* Location)
{
	for (int i = 0; i < Subscriber.Num(); ++i)
	{
		if (Subscriber[i]->IsEquipped)
		{
			Subscriber[i]->ActivateEffect(EffectInstigator, Location);
		}
	}
}

void UTrigger::FillThreshold(float Amount)
{
	if (!Active)
	{
		CurrentThreshold += Amount;
		if (CurrentThreshold >= ThreshHold)
		{
			if (!ShouldUpdate)
			{
				const FVector Location = PLAYER->GetActorLocation();
				NotifySubscribers(PLAYER, &Location);
			}
			else
			{
				CurrentTriggerFrequency = TriggerFrequency;
				Activate();
			}
		}
	}
}

Encounter scripting and enemy pacing

Spawn rules that respect the player

I also worked on encounter flow, including the enemy spawn system and the Destructor enemy. For spawning, an important requirement was fairness. Enemies should appear often enough to keep the level alive, but not pop into existence right in front of the player or inside occupied spaces. The spawn logic checks several conditions before allowing a new enemy into the world.

void AEnemySpawn::SpawnUpdate()
{
	if (
		GameModeRef->CheckIfEnemyCanSpawn(EnemyType) &&
		CheckDistance() &&
		!CheckForCharactersAtSpawn() &&
		!Helper::CheckIfLocationIsInFOV(GetWorld(), GetActorLocation()))
	{
		SpawnEnemy();
	}
}

bool AEnemySpawn::CheckDistance() const
{
	const float CurrentDistanceToPlayer = FVector::Dist(
		GetActorLocation(),
		PLAYER->GetActorLocation());

	return CurrentDistanceToPlayer < MaxDistanceToPlayer
		&& CurrentDistanceToPlayer > MinDistanceToPlayer;
}

The combination of global enemy limits, min and max player distance, overlap checks, and field-of-view checks helped keep encounters believable. It is a small piece of code, but it has a big effect on how polished moment-to-moment gameplay feels. Spawn systems are often invisible when they work well, and that was exactly the goal here.

The Destructor enemy

For the enemy side, I implemented the Destructor, a stationary threat that tracks the player by rotating its platform and cannon, then fires a beam attack. Its behavior was built using a simple state machine. Since the Destructor is a fairly straightforward enemy, just two states were enough to cover its full logic.

void UDestructorIdle::Update(float DeltaTime)
{
	check(DESTRUCTOR);
	
	//Check if player is in Range
	if (DESTRUCTOR->CheckIfPlayerInRange())
	{
		if (!DESTRUCTOR->GetWorld())
		{
			Log::Print("World not found");
			return;
		}
		DESTRUCTOR_SM->StateTransition(DESTRUCTOR_SM->ActiveState);
	}


	//If destructor got hit by player immediately start charging
	if (DESTRUCTOR->GotHit)
	{
		if (!DESTRUCTOR->GetWorld())
		{
			Log::Print("World not found");
			return;
		}
		DESTRUCTOR_SM->StateTransition(DESTRUCTOR_SM->ActiveState);
	}
}

void UDestructorActive::Update(float DeltaTime)
{
	if (!ValidateState()) { return; }
	Super::Update(DeltaTime);

	check(DESTRUCTOR)

	//Check if destructor has reloaded
	if (DESTRUCTOR->Reloaded)
	{
		DESTRUCTOR->Reloaded = false;

		//If destructor got hit recently or player is in range start charging again else go back in idle state
		if (DESTRUCTOR->GotHit || DESTRUCTOR->CheckIfPlayerInRange())
		{
			DESTRUCTOR->StartCharging();
		}
		else
		{
			DESTRUCTOR_SM->StateTransition(DESTRUCTOR_SM->IdleState);
		}
	}
	//If charging or firing a laser change size of vfx and damage object
	else if (DESTRUCTOR->Charging || DESTRUCTOR->FiringLaser)
	{
		DESTRUCTOR->ChangeLaserSize();
	}

	//If player character is not to close rotate destructor barrel towards player
	if (FVector::Dist(PLAYER->GetActorLocation(), DESTRUCTOR->GetActorLocation()) >= 1500.f)
		DESTRUCTOR->RotateToPlayer(DeltaTime);
}

The Destructor is designed to be an easy target when faced directly, but its long range allows it to add pressure during fights with other enemies. The player also cannot afford to stay in its laser beam for too long, as it drains HP very quickly.