Gunnar Magnussen

Gameplay Programmer | Game feel, physics, graphics and systems

DarkSwarm

manufacturing  Engine:  Unreal description  Language:  C++ & Blueprint list  Tasks:  Aiming | Weapon System | Multiplayer groups  Team Size:  ~15 link  Links:  Steam Page | Game Website | Trailer My task at Bitfire was to rework the existing aiming and weapon system to allow for all their future weapon plans, get the aiming to feel just right in every situation, and optimize it to work with more enemies. The aiming system had to take 2D input and translate it to a target in 3D space, which felt right to the player. The weapon system is highly modular, is replicated in a client-server architecture, and uses Unreal's Gameplay Ability System for abilities and interactions.
Everything shared here is done so with express permission from BitFire Games.

Aiming

The aiming system in DarkSwarm takes a 2D input direction and translates it into a 3D aim direction. Classical twin-stick shooters directly map input to aim direction. DarkSwarm, however, has a completely 3D environment, and I was tasked with making the aiming system work in 3 dimensions in a way that would feel intuitive and fun to players.

The solution requires thinking in two different coordinate systems: that of the 3D world, and that of the player's 2D window into the world. A direction that looks like it might hit a target from one perspective might completely miss from another perspective, since the y-axis of the window represents both the y and the z-axis of the game world.

To transform the 2D input direction to a higher dimension, we construct a 3D aim plane based on the player's input and view direction. Within this plane we can aim at any point and it will look like the same direction from the player's perspective.

This aim plane is used to find potential targets and then finally output a 3D aim direction.

To find potential targets, we do a capsule sweep in the direction and orientation of the aim plane.

void UDS_AimingComponent::FindPotentialTargets(TArray<FHitResult>& OutTargets)
{
	FCollisionQueryParams Params;
	Params.bIgnoreBlocks = true;
	Params.bTraceComplex = false;

	FVector TraceEnd = AimOriginLocation + InputDirection * MaxAimDistance;
	FQuat TraceCapsuleOrientation = CameraDirection.ToOrientationQuat().GetUpVector().ToOrientationQuat();

	GetWorld()->SweepMultiByObjectType(
		OutTargets, 
		AimOriginLocation, TraceEnd, 
		TraceCapsuleOrientation, TargetsObjectParams, CollisionShape, Params);
}
		

To find the location on the target's collision capsule that the system should aim towards, we think about the first and last point the aim plane will intersect as the player aims across the target.

FVector UDS_AimingComponent::FindAimLocationOnTargetCapsule(const FHitResult& Target)
{
	// find bottom and top half-height of cylinder
	UCapsuleComponent* EnemyCapsule = Target.GetActor()->GetComponentByClass<UCapsuleComponent>();
	FVector TargetCenter = Target.GetActor()->GetActorLocation();
	float CylinderHalfHeight = EnemyCapsule->GetScaledCapsuleHalfHeight() - EnemyCapsule->GetScaledCapsuleRadius();
	FVector CapsuleEndA = TargetCenter + FVector::UpVector * CylinderHalfHeight;
	FVector CapsuleEndB = TargetCenter - FVector::UpVector * CylinderHalfHeight;

	// Add radius in the direction of the aim plane normal
	FVector AimPlaneUpVector = FVector::CrossProduct(InputDirection, CameraDirection).GetSafeNormal();
	FVector RadDir = AimPlaneUpVector * EnemyCapsule->GetScaledCapsuleRadius();

	if (RadDir.Z > 0.0f)
	{
		CapsuleEndA += RadDir;
		CapsuleEndB -= RadDir;
	}
	else
	{
		CapsuleEndA -= RadDir;
		CapsuleEndB += RadDir;
	}

	// Find the endpoints A-B vector's intersection point with the aim plane
	FVector CapsuleAimRay = CapsuleEndB - CapsuleEndA;

	return FMath::RayPlaneIntersection(CapsuleEndA, CapsuleAimRay, FPlane(AimOriginLocation, AimPlaneUpVector));
}
		

There are many more functions to account for different input types, line of sight, prioritizing targets, etc. Getting to the solution that felt just right to players was an iterative process involving multiple playtests with the team, constant dialogue with designers and animators, and working in a testbed level I created to test and iron out all edge cases.


Weapon System

The weapon system is built to be modular. For example, projectiles can be easily swapped out and different components such as scopes and flashlights can be added to different weapons. Using Unreal's Data Assets, the weapon stats are easy for the designers to tweak. Some parts are inspired by Unreal's Lyra project, but tailored to DarkSwarm's specific needs.

Using Unreal's Gameplay Ability System (GAS), allows us to create complex interactions. For example, weapon fire can be blocked by tags such as 'Downed' or 'Trapped'. GAS also lets each weapon behave in completely unique ways, by granting the player a different set of abilities.

I build an inventory component for the player character to handle what weapons are currently equipped. The inventory uses an asset manager to make sure we only load the necessary weapons.

To design this system I set up and held meetings with the rest of the tech team and was in dialogue with the designers about what they wanted and how to best accomodate their workflows.


Multiplayer

DarkSwarm is a co-op focused game and uses a client-server networking architecture. The server is authoritative, so important events are sent to the server, that then replicates valid events to all clients.

This means that the server is always ahead of the clients. To make the game feel responsive to players on client machines, we have to predict the outcome. This is mostly handled by GAS, but things such as spawning projectiles would require custom prediction logic.

Everything I worked on had to work in a multiplayer setting: The aim system replicates the aim direction, and the weapon system replicates what weapon is equipped and the attacks with those weapons.

// RPC functions in a weapon system header file
public:
	UFUNCTION(Server, Reliable)
	void Server_BeginAttack();
	UFUNCTION(NetMulticast, Reliable)
	void NetMulti_BeginAttack();
	
// Replicated properties setup in the aiming system
void UDS_AimingComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(UDS_AimingComponent, AimLocation);
	DOREPLIFETIME(UDS_AimingComponent, AimOriginLocation);
}