Roles: Gameplay Programmer, Programming Lead, Producer
Team Size: 16
Engine/Language: Unreal, C++
Skybooter is a first-person shooter where the player will jump, swing, and blast their way across enemy ships in the sky in order to take down their enemies and steal anything not bolted down. Machinery has brought the seven seas to the clouds, and it’s your job to plunder as much as possible from the other residents of the sky.
Skybooter was a 15-week development project focused on delivering a polished vertical slice of a AAA-style game. The result is a first-person, momentum-driven shooter where players control Captain Sky, a lone sky pirate who boards enemy airships, engages in high-mobility combat, and escapes with stolen loot.
On this project, I served as Producer, Programming Lead, and Gameplay Programmer, balancing both technical implementation and team leadership. My primary programming contributions included developing the player controller, implementing three physics-based weapons, and building the animation system.
In my leadership roles, I was responsible for managing a 16-person multidisciplinary team. This included creating and assigning tasks, conducting code reviews, resolving merge conflicts, and maintaining branch organization and version control workflows. I also facilitated team meetings and acted as the primary point of contact for technical problem-solving, supporting team members in debugging and unblocking development issues.
As Producer, I was ultimately responsible for high-level decision-making, particularly when navigating trade-offs between scope, technical constraints, and design goals. This required consistently evaluating problems from multiple perspectives, technical, artistic, and production, to keep the project aligned and moving forward.
The first-person player controller serves as the foundation of Skybooter’s gameplay experience. Players control the character using mouse and keyboard inputs, with the core movement system intentionally designed around a relatively limited base moveset until expanded through the use of the game’s physics-based weapons. Despite this, significant attention was given to ensuring the base controller felt responsive, polished, and technically robust.
Because the game utilized a full-body character model during third-person gameplay segments, special care was required to correctly synchronize camera movement with the player model and animation system. Camera positioning introduced additional technical challenges, as the camera needed to move in front of the player character while the collision capsule needed to remain large enough to fully encapsulate the equipped weapons. This created situations where the player’s feet could visually extend beyond platform edges. To address this, a custom ledge detection system was implemented to automatically reposition the player when overextending beyond valid ground surfaces.
To better support the game’s high-speed traversal and momentum-focused gameplay, a ledge grab system was also implemented. This mechanic provided players with additional forgiveness during fast movement sequences, allowing smoother recovery and more fluid navigation between platforms and ships.
A significant amount of work also went into the player animation pipeline. This included configuring animation states, sockets, animation groups, and animation priority handling within the Animation Blueprint to ensure responsive and context-aware animation playback. Integrating these animations with gameplay systems required careful coordination to maintain smooth transitions, preserve responsiveness during movement and combat, and prevent conflicting animation behaviors.
The player camera utilized a dynamic offset system designed to improve visibility and maintain proper framing across a wide range of viewing angles. Because Skybooter uses a full-body player model, a static first-person camera position frequently resulted in clipping issues and obstructed visibility, particularly when looking sharply upward or downward.
To solve this, the camera’s relative position was updated each frame based on the player’s pitch rotation. Trigonometric calculations were used to derive both forward and vertical camera offsets from the current viewing angle, allowing the camera to smoothly reposition itself depending on where the player was looking. Different offset scaling values were applied when looking upward versus downward in order to maintain consistent framing and avoid excessive camera penetration into the character model.
This system helped preserve player visibility during traversal and combat while also improving the overall feel of movement by making camera motion appear more physically connected to the character body and animations.
void APlayerCharacter::UpdateCameraOffset() const
{
// Convert pitch to radians
const float pitchDeg = GetControlRotation().Pitch - 90.f;
const float rad = FMath::DegreesToRadians(pitchDeg);
// Calculate forward (X) and vertical (Z)
const float cosVal = -FMath::Cos(rad);
const float sinVal = -(1 + FMath::Sin(rad));
float newX;
float newZ;
// Adjust offsets depending on look direction
if (cosVal > 0)
{
newX = cosVal * 25.f;
newZ = sinVal * 50.f + 64.f;
}
else
{
newX = cosVal * 40.f;
newZ = sinVal * 30.f + 64.f;
}
// Apply new relative camera position
Camera->SetRelativeLocation(FVector(newX - 15, 0.f, newZ));
}
Because the player camera sits in front of the player model, situations could occur where the visible character model appeared to stand partially off a ledge while the capsule itself remained safely grounded. This created noticeable visual inconsistencies, particularly during traversal and combat near platform edges. To address this, a custom ledge correction system was implemented to monitor whether the player mesh remained properly supported by surrounding geometry.
The system continuously performed downward line traces from multiple positions around the player mesh while grounded. An initial trace checked whether the center of the mesh was still positioned above valid ground geometry. If the center remained supported, no correction was required. When the center was no longer above ground, additional traces were performed behind and to both sides of the character to determine which direction lacked supporting geometry.
if (bCanFallOffLedge && GetCharacterMovement()->IsMovingOnGround())
{
FVector MeshLocation = GetMesh()->GetComponentLocation();
/// Check if mesh is over ground
FVector DownEnd = MeshLocation - FVector(0, 0, 100.f);
FHitResult DownHit;
FCollisionQueryParams DownParams;
DownParams.AddIgnoredActor(this);
bool bMeshOverGround = GetWorld()->LineTraceSingleByChannel(
DownHit,
MeshLocation,
DownEnd,
ECC_Visibility,
DownParams
);
/// Stop if mesh is over ground
if (bMeshOverGround)
{
return;
}
FVector BackDir = -GetActorForwardVector();
FVector RightDir = GetActorRightVector();
FVector LeftDir = -GetActorRightVector();
auto IsGroundMissing = [&](FVector Dir)
{
FVector Start = MeshLocation + Dir * 80.f;
FVector End = Start - FVector(0, 0, 100.f);
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(this);
return !GetWorld()->LineTraceSingleByChannel(
Hit,
Start,
End,
ECC_Visibility,
Params
);
};
Using the trace results, the system identified which side of the player was no longer supported by ground geometry and applied a small corrective movement force in that direction. Rather than artificially snapping the character back onto the platform, this intentionally pushed the player off the edge to create a more natural gravity-driven response. This approach prevented the player model from visibly floating or overextending beyond ledges while still preserving smooth movement and player control.
FVector PushDir = FVector::ZeroVector;
bool bRightOff = IsGroundMissing(RightDir);
bool bLeftOff = IsGroundMissing(LeftDir);
// Pick fall off direction
if (bRightOff && !bLeftOff)
{
PushDir = RightDir;
}
else if (bLeftOff && !bRightOff)
{
PushDir = LeftDir;
}
else if (IsGroundMissing(BackDir))
{
PushDir = BackDir;
}
if (!PushDir.IsNearlyZero())
{
AddMovementInput(PushDir.GetSafeNormal(), 0.3f);
}
}
}
By separating visual grounding logic from the primary collision capsule, the system allowed the camera and weapon positioning to remain flexible without sacrificing animation believability or environmental readability.
To support Skybooter’s fast-paced traversal system, a custom ledge grab mechanic was implemented to give players additional recovery options during high-speed movement. Because players frequently launch themselves between platforms and airships using physics-based weapons, traditional platforming precision would often feel overly punishing. The ledge grab system was designed to preserve movement flow while still maintaining player control and momentum.
A valid ledge grab was detected using a dual line-trace approach while the player was airborne. The first trace originated from the player’s lower body and projected forward to detect whether a wall or ledge surface existed directly in front of the character. A second trace was projected forward from a higher position near the player’s upper body to determine whether the space above that surface was clear. If the lower trace detected a surface while the upper trace did not register a collision, the system identified the geometry as a valid ledge that the player could climb onto.
This approach provided a lightweight and reliable method of differentiating climbable ledges from full walls or blocked surfaces. By separating the collision checks into lower and upper traces, the system could verify both the existence of a reachable surface and the availability of space required for the character capsule to safely transition onto the platform.
// Decide if the player can grab ledge
if (!GetCharacterMovement()->IsMovingOnGround() && !bIsGrabbing)
{
// Upper trace — checks if space above ledge is clear
FVector grabStart = GrabRaycastOrigin->GetComponentLocation();
FVector grabEnd = grabStart + GrabRaycastOrigin->GetForwardVector() * MinLedgeSize;
FHitResult grabHit;
bool grabResult = GetWorld()->LineTraceSingleByChannel(grabHit, grabStart, grabEnd, ECC_Visibility);
// Lower trace — checks if wall exists in front of body
FVector bodyStart = BodyRaycastOrigin->GetComponentLocation();
FVector bodyEnd = bodyStart + BodyRaycastOrigin->GetForwardVector() * MaxDistanceFromLedge;
FHitResult bodyHit;
bool bodyResult = GetWorld()->LineTraceSingleByChannel(bodyHit, bodyStart, bodyEnd, ECC_Visibility);
// If wall detected but upper space is clear, ledge detected
if (bodyResult && !grabResult)
{
GrabLedge(GetMesh()->GetForwardVector());
}
}
When a valid ledge grab is detected, player movement input is temporarily disabled and the character movement mode is switched to a controlled flying state. This immediately stops existing velocity and prevents gravity from interfering with the transition sequence. From there, a staged movement process is executed using timed function calls to create a smoother and more readable climb animation.
codevoid APlayerCharacter::GrabLedge(const FVector& TowardsLedge)
{
if (bDied) return;
bCanFallOffLedge = false;
OnPlayerGrabLedge();
// Get custom player controller and disable movement input
APlayerCharacterController* playerController = Cast<APlayerCharacterController>(GetController());
playerController->SetAcceptMovementInput(false);
bIsGrabbing = true;
// stop movement and gravity
GetCharacterMovement()->SetMovementMode(MOVE_Flying);
GetCharacterMovement()->StopMovementImmediately();
FTimerDelegate delegate;
delegate.BindUFunction(this, FName("PullUp"), TowardsLedge);
GetWorld()->GetTimerManager().SetTimer(
TimerHandle,
delegate,
0.1f,
false);
}
The first stage applies an upward launch force to pull the player vertically toward the ledge.
codevoid APlayerCharacter::PullUp(const FVector& TowardsLedge)
{
OnPullUp();
LaunchCharacter(GetActorUpVector() * PullUpToLedgeForce, true, true);
FTimerDelegate delegate;
delegate.BindUFunction(this, FName("StepForward"), TowardsLedge);
GetWorld()->GetTimerManager().SetTimer(
TimerHandle,
delegate,
0.5f,
false
);
}
After a short delay, a second forward launch force moves the character fully onto the platform surface.
void APlayerCharacter::StepForward(const FVector& TowardsLedge)
{
LaunchCharacter(TowardsLedge * StepForwardToLedgeForce, true, true);
GetWorld()->GetTimerManager().SetTimer(
TimerHandle,
this,
&APlayerCharacter::ReenableMovement,
0.1f,
false
);
}
Once the sequence is complete, movement input is restored, the character movement mode returns to walking, and normal player control resumes.
void APlayerCharacter::ReenableMovement()
{
APlayerCharacterController* playerController = Cast<APlayerCharacterController>(GetController());
playerController->SetAcceptMovementInput(true);
GetCharacterMovement()->SetMovementMode(MOVE_Walking);
bIsGrabbing = false;
bCanFallOffLedge = true;
}
A significant amount of my work on Skybooter involved building and maintaining the animation systems for both the player and enemy characters. I was responsible for implementing the complete player animation pipeline as well as integrating all animations for the game's three enemy types. This included configuring Animation Blueprints, state machines, animation montages, sockets, layered animation blending, and animation priority systems to ensure animations played correctly across a wide variety of gameplay situations.
One of the primary challenges was managing the large number of competing animation states required by the game's movement and weapon systems. The player could be running, jumping, swinging from a harpoon, reeling, reloading, attacking, dashing, firing weapons, taking damage, or dying, often with multiple actions occurring simultaneously. To handle this complexity, I utilized Unreal Engine's Animation Blueprint framework in combination with multiple animation groups and slots to establish clear animation priorities and blending behavior.
The player animation graph was designed around a layered architecture that separated full-body movement from higher-priority gameplay actions. Animation slots and groups allowed weapon actions, combat abilities, reloads, and special movement states to selectively override portions of the animation hierarchy without disrupting the entire character pose. This approach provided fine grain control over animation playback while preventing conflicts between overlapping gameplay systems.
In addition to animation state management, I implemented bone modifications within the Animation Blueprint to support gameplay-specific functionality. These included character look rotation adjustments, weapon visibility management, and dynamic weapon positioning. By driving these behaviors directly through the animation graph, I was able to reduce the need for duplicate animations while maintaining flexibility across the player’s different weapons and abilities.
The Blunderbuss is the first of the three physics-based weapons available to the player and serves as a core part of Skybooter’s combat and traversal systems. Designed as a mid-range shotgun-style weapon, it combines responsive hitscan combat with momentum-based movement mechanics. Rather than using physical projectiles, the weapon utilizes a custom multi-trace hitscan system with box sweeps arranged in a circular spread pattern to simulate fragmented ammunition while maintaining reliable hit registration. VFX and tracer systems create the illusion of randomized pellet spread without sacrificing gameplay consistency. The weapon also features distance-based damage falloff, directional enemy knockback, airborne player recoil movement, and a custom camera recoil system driven by FRichCurve interpolation. Together, these systems allow the Blunderbuss to function as both a combat weapon and a traversal tool while emphasizing gameplay feel and responsiveness over strict realism.
The Blunderbuss firing system was built around a custom multi-trace hitscan implementation designed to simulate the feel of a shotgun while maintaining reliable and responsive gameplay. Instead of spawning physical projectiles, the weapon performs a series of box sweep traces originating from the player camera, ensuring shots align precisely with the player’s aim regardless of weapon or camera positioning.
/// Get the camera position and rotation so the weapon fires where the player is aiming
FVector cameraLocation;
FRotator cameraRotation;
Controller->GetPlayerViewPoint(cameraLocation, cameraRotation);
/// Stores all hit results from the sweeps
TArray<FHitResult> hitResults;
/// Setup collision query parameters and ignore the weapon and its owner
FCollisionQueryParams traceParams;
traceParams.AddIgnoredActor(this);
traceParams.AddIgnoredActor(GetOwner());
/// Size of the box used for each sweep trace
FVector halfSize = FVector(10, 10, 10);
/// Create forward, right, and up vectors from the camera rotation
FVector forward = cameraRotation.Vector();
FVector right = FRotationMatrix(cameraRotation).GetUnitAxis(EAxis::Y);
FVector up = FRotationMatrix(cameraRotation).GetUnitAxis(EAxis::Z);
/// Track actors hit and the closest hit distance for each
TMap<AActor*, float> damagedActors;
To simulate pellet spread, multiple sweep traces are arranged in a circular grid pattern around the camera’s forward direction. Each sweep is given a positional offset using the camera’s local right and up vectors, creating the illusion of a wide pellet spread while preserving deterministic hit registration. Grid positions outside the intended circular radius are skipped to maintain a more natural spread shape and avoid square-shaped shot patterns.
/// Perform multiple box sweeps arranged in a circular grid pattern
for (float i = -2; i < 3; i++)
{
for (int j = -2; j < 3; j++)
{
/// Skip grid positions outside the circular spread
if ((i * i + j * j) > 2.f * 2.f)
continue;
/// Offset each sweep to simulate shotgun pellet spread
FVector offset = right * j * 50.f + up * i * 50.f;
/// Starting point of the sweep
FVector start = cameraLocation;
/// End point extends forward based on weapon range
FVector end = (start + offset) + forward * Range;
Each sweep uses a small box collision shape rather than a single line trace. This slightly increased hit volume made the weapon feel more forgiving and responsive during fast-paced movement without requiring unrealistic aim precision from the player. All detected hits are collected and processed independently, while a map of damaged actors tracks only the closest impact distance for each target. This prevented actors from taking unintended duplicate damage from overlapping pellet traces while still allowing multiple pellets to visually interact with the environment.
/// Store hits detected by this specific sweep
TArray<FHitResult> sliceHits;
/// Sweep a box from start to end to detect hit actors
GetWorld()->SweepMultiByChannel(
sliceHits,
start,
end,
cameraRotation.Quaternion(),
ECC_Visibility,
FCollisionShape::MakeBox(halfSize),
traceParams
);
/// Process every hit detected in this sweep
for (const FHitResult& hit : sliceHits)
{
AActor* hitActor = hit.GetActor();
if (!hitActor) continue;
/// Add a small random offset so impact VFX are less uniform
FVector jitterOffset(
FMath::FRandRange(-20.f, 20.f),
FMath::FRandRange(-20.f, 20.f),
0.f
);
/// Spawn impact vfx
UNiagaraFunctionLibrary::SpawnSystemAtLocation(
GetWorld(),
HitVFX,
hit.ImpactPoint + jitterOffset,
hit.ImpactNormal.Rotation()
);
float hitDistance = hit.Distance;
/// Track only the closest hit for each actor
if (damagedActors.Contains(hitActor))
{
if (hitDistance < damagedActors[hitActor])
{
damagedActors[hitActor] = hitDistance;
}
}
else
{
damagedActors.Add(hitActor, hitDistance);
}
}
}
}
A large amount of work also went into synchronizing gameplay feedback with the firing system. Niagara tracer VFX were spawned for every sweep trace, visually representing individual pellet paths. While the actual hit detection remained tightly controlled, slight randomized offsets were applied to tracer origins and impact effects to create the appearance of chaotic pellet scattering. Additional randomized offsets were applied to impact VFX locations to reduce visual repetition and make repeated shots feel more dynamic and organic.
/// Create tracer line vfx
if (TracerVFX)
{
/// Set end point of tracer line
FVector tracerEnd = end;
if (sliceHits.Num() > 0)
{
tracerEnd = sliceHits[0].ImpactPoint;
}
/// Spawn tracer line attached to blunderbuss socket
UNiagaraComponent* tracer = UNiagaraFunctionLibrary::SpawnSystemAttached(
TracerVFX,
PlayerMesh,
TEXT("BlunderBussBaseSocket"),
FVector::ZeroVector,
FRotator::ZeroRotator,
EAttachLocation::SnapToTarget,
true
);
/// Offset start location into circular grid
if (tracer)
{
const FTransform socketTransform = PlayerMesh->GetSocketTransform(TEXT("BlunderBussBaseSocket"));
FVector startOffset = socketTransform.GetUnitAxis(EAxis::X) * -i * 1.5f + socketTransform.
GetUnitAxis(EAxis::Z) * j * 1.5f;
tracer->SetVectorParameter(TEXT("Start"), socketTransform.GetLocation() + startOffset);
tracer->SetVectorParameter(TEXT("End"), tracerEnd);
}
}
By separating visual spread from underlying hit logic, the system achieved a balance between cinematic weapon feedback and gameplay consistency. The result was a weapon that felt powerful and unpredictable visually while still remaining responsive and dependable from a player-control perspective.
One of the Blunderbuss’s defining mechanics is its ability to function as both a weapon and a traversal tool through player knockback. When the weapon is fired while the player is airborne, an impulse force is applied opposite to the camera’s forward direction, effectively launching the player backward through the air. This allowed players to use weapon recoil as an intentional movement mechanic, enabling rapid repositioning, aerial recovery, and momentum-based traversal between platforms and ships.
The knockback direction was calculated directly from the player camera’s rotation rather than the character model orientation. This ensured movement remained tightly aligned with player aim and camera control, giving players precise directional influence while airborne. By applying the impulse through the character movement component, the system integrated cleanly with existing momentum and velocity calculations, allowing traversal movement to feel smooth and physically reactive.
void ABlunderbuss::PlayerKnockback(APlayerController* PlayerController, int KnockbackForce) const
{
/// Get the player camera location and rotation for aiming
FVector cameraLocation;
FRotator cameraRotation;
PlayerController->GetPlayerViewPoint(cameraLocation, cameraRotation);
/// Calculate the end location of the trace based on weapon range
FVector cameraForwardVector = cameraRotation.Vector();
/// Apply physical recoil to the player if airborne
APlayerCharacter* playerCharacter = Cast<APlayerCharacter>(GetOwner());
if (!playerCharacter->GetCharacterMovement()->IsMovingOnGround())
{
/// Launch the player backward based on knockback force and firing direction
playerCharacter->GetCharacterMovement()->AddImpulse(-cameraForwardVector * KnockbackForce, true);
}
}
Conditioning the effect to only activate while airborne was an important design decision. This preserved grounded combat readability while allowing the weapon to dramatically expand player mobility during traversal sequences. The result was a movement mechanic that reinforced Skybooter’s core gameplay loop by blending combat actions directly into player locomotion.
To reinforce the impact and weight of the Blunderbuss, a custom camera recoil system was implemented using FRichCurve interpolation. Rather than instantly rotating the camera or applying a simple fixed offset, the recoil system evaluated curve data over time to create a smoother and more controllable recoil response.
When the weapon is fired, a recoil instance is dynamically created to track the progression of the recoil animation. The system samples values from predefined recoil curves at fixed intervals using timed updates, applying pitch input directly to the player controller each step. This allowed recoil behavior to be fully data-driven, making it easy to tune the strength, speed, and shape of the recoil independently for different firing modes or weapons.
struct FRecoilInstance
{
int Step = 0;
float Time = 0.f;
bool bReset = false;
FTimerHandle Handle;
};
void ABlunderbuss::ApplyCameraRecoil(APlayerController* PlayerController, bool Primary)
{
if (!IsValid(PlayerController)) return;
FRecoilInstance* Recoil = new FRecoilInstance();
Recoil->Step = 0;
Recoil->Time = 0.f;
Recoil->bReset = false;
float LocalRecoilTime = 0.f;
if (Primary && PrimaryRecoilCurve)
{
const TArray<FRichCurveKey>& Keys = PrimaryRecoilCurve->FloatCurve.GetConstRefOfKeys();
LocalRecoilTime = Keys.Last().Time;
}
else if (!Primary && SecondaryRecoilCurve)
{
const TArray<FRichCurveKey>& Keys = SecondaryRecoilCurve->FloatCurve.GetConstRefOfKeys();
LocalRecoilTime = Keys.Last().Time;
}
The recoil motion itself was split into two phases. During the first phase, the curve drives the camera upward to simulate the force of the weapon firing. Once halfway through the sequence, the system transitions into a reset phase that smoothly returns the camera toward its original position. Separating the recoil into staged phases helped prevent abrupt camera snapping and created a more polished and responsive firing experience.
FTimerDelegate RecoilDelegate = FTimerDelegate::CreateLambda(
[this, PlayerController, Primary, Recoil, LocalRecoilTime]()
{
if (!PlayerController || !Recoil) return;
float Value = 0.f;
if (Primary && PrimaryRecoilCurve)
{
Value = PrimaryRecoilCurve->GetFloatValue(Recoil->Time);
}
else if (!Primary && SecondaryRecoilCurve)
{
Value = SecondaryRecoilCurve->GetFloatValue(Recoil->Time);
}
PlayerController->AddPitchInput(Value);
Recoil->Step++;
Recoil->Time += LocalRecoilTime / RecoilSteps;
if (Recoil->Step >= RecoilSteps / 2 && !Recoil->bReset)
{
Recoil->Step = 0;
Recoil->bReset = true;
}
else if (Recoil->Step >= RecoilSteps / 2 && Recoil->bReset)
{
GetWorld()->GetTimerManager().ClearTimer(Recoil->Handle);
delete Recoil;
}
}
);
GetWorld()->GetTimerManager().SetTimer(
Recoil->Handle,
RecoilDelegate,
LocalRecoilTime / RecoilSteps,
true
);
}
Using FRichCurve assets instead of hardcoded values also provided significant iteration flexibility during development. Designers could rapidly tweak recoil timing, intensity, and recovery behavior directly within the editor without requiring code changes. The curve-based approach additionally allowed for far more detailed and expressive recoil patterns, enabling subtle variations in acceleration and recovery that would have been difficult to achieve using simple linear interpolation or fixed-value recoil systems.
The Blunderbuss was designed to heavily emphasize impact and crowd control during combat encounters. In addition to dealing damage, the weapon applies directional knockback to enemies, allowing players to manipulate enemy positioning and create space during close-range engagements.
Each successful pellet hit contributes to a shared damage calculation system that tracks unique actors hit during the shot. Damage is then scaled using distance-based falloff, causing enemies closer to the player to receive significantly higher damage than those farther away. This reinforced the weapon’s intended role as a close- to mid-range combat tool while rewarding aggressive positioning and accurate movement.
/// Apply damage and effects to all unique actors that were hit
for (auto& pair : damagedActors)
{
AActor* hitActor = pair.Key;
float hitDistance = pair.Value;
if (!hitActor) continue;
/// Trigger explosion if the actor is an explosive barrel
if (AExplodingBarrel* barrel = Cast<AExplodingBarrel>(hitActor))
{
barrel->Explode();
}
/// Calculate damage falloff based on distance from the player
int hitDamage = ((Range - hitDistance) / Range) * CurrentDamage;
/// Apply damage to the actor
UGameplayStatics::ApplyDamage(
hitActor,
hitDamage,
Controller,
this,
nullptr
);
When an enemy is struck, a knockback direction is calculated using the vector between the player and the impacted target. The vertical component of the direction is clamped to prevent enemies from being launched downward, ensuring knockback remained visually readable and mechanically useful during combat. Before applying launch forces, enemy AI movement is temporarily interrupted to prevent navigation systems from conflicting with the physics response.
Enemies are then launched using a combination of horizontal and vertical forces, producing an intentionally exaggerated reaction designed to prioritize gameplay feel over realism. The added vertical force was particularly important, as it allowed enemies to be launched upward and over ship railings, creating opportunities for players to knock enemies entirely off the airships during combat. This reinforced the game’s physics-driven sandbox gameplay while encouraging players to use weapon positioning and environmental awareness strategically during encounters.
/// Determine knockback direction from player to hit actor
FVector KnockbackDir = hitActor->GetActorLocation() - GetOwner()->GetActorLocation();
if (KnockbackDir.Z < 0)
{
KnockbackDir.Z = 0;
}
KnockbackDir.Normalize();
/// Apply knockback if the hit actor is an enemy
if (AEnemyBase* HitEnemy = Cast<AEnemyBase>(hitActor))
{
/// Stop AI movement before applying launch force
if (AController* SolCon = HitEnemy->GetController())
{
SolCon->StopMovement();
}
/// Launch enemy using horizontal and vertical knockback forces
HitEnemy->LaunchCharacter(
(KnockbackDir * EnemyKnockbackForce.X) + FVector::UpVector * EnemyKnockbackForce.Y, true, true);
}
The weapon also interacted with environmental gameplay objects such as explosive barrels, allowing shots to trigger chain reactions and further reinforce the chaotic, physics-driven combat style central to Skybooter.
The Sword is the second of the three physics-based weapons available to the player and was designed around aggressive close-range combat and precise mobility control. The weapon combines a responsive melee attack system with a directional dash ability that functions as both a traversal mechanic and offensive tool. Its primary attack uses a custom multi-hitbox sweep system for reliable melee collision against multiple enemies and around small environmental geometry, while the secondary dash ability allows players to quickly redirect or stabilize their movement during traversal and combat. Throughout the weapon’s design, a strong emphasis was placed on responsiveness, gameplay feel, and player control, reinforcing Skybooter’s focus on fluid movement and physics-driven combat interactions.
The Sword’s primary attack was designed to support responsive close-range combat while maintaining the fast-paced, mobility-focused gameplay central to Skybooter. The attack system used multiple box sweep hitboxes projected forward from the player camera rather than a single collision volume attached directly to the weapon model. These sweeps were arranged in a layered grid pattern using offsets based on the camera’s right and up vectors, allowing the slash to cover a wider attack area while remaining aligned with player aim.
Using multiple hitboxes provided several gameplay advantages. It allowed the sword to reliably strike multiple enemies in a single swing while also helping attacks connect around small pieces of environmental geometry that might otherwise unintentionally block melee collisions. This approach made the weapon feel significantly more responsive and forgiving during fast traversal and close-quarters combat encounters.
/// Prepare a hit result to store the outcome of the line trace
TArray<FHitResult> hitResults;
/// Setup collision parameters for the trace
FCollisionQueryParams traceParams;
traceParams.AddIgnoredActor(this);
traceParams.AddIgnoredActor(GetOwner());
/// Calculate Direction Vectors from Camera Rotation
FVector forward = cameraRotation.Vector();
FVector right = FRotationMatrix(cameraRotation).GetUnitAxis(EAxis::Y);
FVector up = FRotationMatrix(cameraRotation).GetUnitAxis(EAxis::Z);
/// Track Unique Damaged Actors
TSet<AActor*> damagedActors;
// Perform Multi-Slice Box Sweeps
// Creates a 2 (vertical) x 3 (horizontal) grid of box sweeps
for (int i = 0; i < 2; i++)
{
for (int j = -1; j <= 1; j++)
{
/// Offset each slice relative to camera
FVector offset = right * j * 33.f + up * i * 50.f;
/// Start position of this slice
FVector start = cameraLocation + offset;
/// End position extends forward by weapon range
FVector end = start + forward * Range;
/// Store hits for this individual slice
TArray<FHitResult> sliceHits;
/// Perform box sweep along the slice path
GetWorld()->SweepMultiByChannel(
sliceHits,
start,
end,
cameraRotation.Quaternion(),
ECC_Visibility,
FCollisionShape::MakeBox(SwingHalfSize),
traceParams
);
/// Process all hits from this slice
for (const FHitResult& hit : sliceHits)
{
AActor* hitActor = hit.GetActor();
if (!hitActor) continue;
/// Skip actors already processed
if (damagedActors.Contains(hitActor))
continue;
/// Add unique actor to damage list
damagedActors.Add(hitActor);
}
}
}
To prevent enemies from taking duplicate damage from overlapping traces, all impacted actors were stored in a unique actor set before damage processing occurred. Once tracing was complete, damage and gameplay effects were applied only once per target. This ensured combat interactions remained consistent regardless of how many sweep slices intersected the same enemy.
/// Apply Effects to all Unique Hit Actors
for (AActor* hitActor : damagedActors)
{
if (!hitActor) continue;
// Exploding barrel
if (AExplodingBarrel* barrel = Cast<AExplodingBarrel>(hitActor))
{
barrel->Explode();
}
// Apply damage
UGameplayStatics::ApplyDamage(
hitActor,
Damage,
Controller,
this,
nullptr
);
}
}
The attack system also incorporated a lightweight combo mechanic that alternated slash direction between consecutive swings. This was used both to improve visual variety and to make repeated attacks feel more fluid and dynamic. Niagara slash VFX were spawned relative to the player camera and mirrored depending on combo direction, helping reinforce the motion and directionality of each swing.
if (SlashVFX)
{
/// Offset where the slash effect appears relative to the camera
FVector spawnOffset = FVector(50.f, 0.f, -20.f);
/// Scale of the slash visual effect
FVector spawnScale = FVector(0.5f, 0.5f, 1.f);
/// Rotation offset used to orient the slash effect
FRotator slashOffset;
/// Flip the slash direction depending on swing order
if (cacheSwingDirection)
{
slashOffset = FRotator(-180.f, 0.f, 30.f);
}
else
{
slashOffset = FRotator(0.f, 180.f, -30.f);
}
/// Spawn the slash Niagara system attached to the camera
UNiagaraComponent* NiagaraComp =
UNiagaraFunctionLibrary::SpawnSystemAttached(
SlashVFX,
Camera,
NAME_None,
spawnOffset,
slashOffset,
EAttachLocation::KeepRelativeOffset,
true
);
/// Apply scale to the Niagara effect
if (NiagaraComp)
{
NiagaraComp->SetRelativeScale3D(spawnScale);
}
}
Because the sword was intended to feel highly responsive, visual feedback and hit detection were tightly synchronized. The result was a melee system that remained reliable during high-speed gameplay while still providing strong visual impact and readable combat interactions.
The Sword’s secondary attack was designed as a high-speed dash ability that combined traversal, survivability, and offensive pressure into a single movement mechanic. The dash launches the player forward in the direction of the camera, allowing rapid repositioning during combat while also enabling players to quickly traverse gaps and move between platforms or ships.
When activated, the player is propelled forward using LaunchCharacter based on the camera’s forward vector rather than the character model orientation. This ensured the dash remained tightly aligned with player aim and camera control, making movement feel precise and responsive during high-speed gameplay. Unlike many momentum-based movement abilities in Skybooter, the dash was intentionally designed to override and reset the player’s existing velocity. This allowed the mechanic to function as a reliable form of momentum control, giving players a way to quickly stabilize or redirect themselves during traversal and combat sequences.
To further support controlled movement during the dash, gravity is temporarily disabled before being restored shortly afterward through a timed callback. This prevented unwanted downward acceleration from interfering with the dash trajectory and helped maintain a clean, readable movement arc.
/// Get the player camera location and rotation for dash direction
FVector cameraLocation;
FRotator cameraRotation;
playerController->GetPlayerViewPoint(cameraLocation, cameraRotation);
/// Dashes the player forward in look direction
playerCharacter->LaunchCharacter(cameraRotation.Vector() * DashForce, true, true);
/// Turn off gravity during dash
playerMovementComponent->GravityScale = 0.0f;
GetWorld()->GetTimerManager().ClearTimer(SwordDashGravityTimerHandler);
GetWorld()->GetTimerManager().SetTimer(
SwordDashGravityTimerHandler,
[this]()
{
if (playerCharacter && playerMovementComponent)
{
playerMovementComponent->GravityScale = 1.f;
}
},
DashGravityOffTime,
false
);
The ability also grants temporary invincibility frames during execution, allowing players to aggressively engage enemies or escape dangerous situations without immediately taking damage. To balance the mechanic, the dash uses a charge-based system combined with cooldown management, requiring players to strategically manage mobility resources during combat encounters.
// Add invincibility
playerCharacter->AddInvincibility(DashInvincibilitySeconds);
// Wait for cooldown to use again
SetCanUseSecondary(false);
FTimerDelegate delegate;
delegate.BindUFunction(this, FName("SetCanUseSecondary"), true);
GetWorld()->GetTimerManager().SetTimer(
SecondaryCooldownTimerHandle,
delegate,
SecondaryCooldownTime,
false);
In addition to traversal, the dash functions as an offensive attack through a continuously evaluated hitbox sweep system. During the dash, box sweeps are projected forward from the player camera to detect enemies along the dash path. Using sweep traces instead of a static collision volume ensured the attack remained reliable even at high movement speeds, preventing enemies from being skipped due to rapid player motion.
void ASword::DashHitbox()
{
/// Array to store all hit results from the sweep
TArray<FHitResult> hitResults;
/// Ignore player and self collision
FCollisionQueryParams traceParams;
traceParams.AddIgnoredActor(this);
traceParams.AddIgnoredActor(GetOwner());
/// Get the current viewpoint of the player (camera position and direction)
FVector cameraLocation;
FRotator cameraRotation;
playerController->GetPlayerViewPoint(cameraLocation, cameraRotation);
/// Calculate start and end positions of the hitbox
FVector start = cameraLocation;
FVector end = start + cameraRotation.Vector() * 200.f;
/// Perform a box sweep from start to end to detect actors in the dash path
GetWorld()->SweepMultiByChannel(
hitResults,
start,
end,
cameraRotation.Quaternion(),
ECC_Visibility,
FCollisionShape::MakeBox(DashHalfSize),
traceParams);
Actors hit during the dash are stored in a unique hit list to prevent repeated damage application within a single attack sequence. Enemies struck by the dash receive both damage and directional knockback forces, with additional upward force applied to improve combat readability and allow enemies to be launched over railings or off ships entirely. Similar to the Blunderbuss, the knockback behavior intentionally prioritized satisfying gameplay feedback and environmental interaction over strict physical realism.
/// Loop through every actor hit by the sweep
for (const FHitResult& hit : hitResults)
{
AActor* actor = hit.GetActor();
/// Skip if the actor is invalid or has already been hit during this dash
if (!actor || DashHitActors.Contains(actor))
{
continue;
}
/// Add actor to the list so it can't be hit again during the same dash
DashHitActors.Add(actor);
/// If the actor is an exploding barrel, trigger its explosion
if (AExplodingBarrel* barrel = Cast<AExplodingBarrel>(actor))
{
barrel->Explode();
}
/// Calculate Knockback Direction
FVector KnockbackDir = actor->GetActorLocation() - playerCharacter->GetActorLocation();
if (KnockbackDir.Z < 0)
{
KnockbackDir.Z = 0;
}
KnockbackDir.Normalize();
/// Apply knockback to Character
if (AEnemyBase* HitEnemy = Cast<AEnemyBase>(actor))
{
if (AController* SolCon = HitEnemy->GetController())
{
SolCon->StopMovement();
}
// Apply the physical launch
HitEnemy->LaunchCharacter(
(KnockbackDir * EnemyKnockbackForce.X) + FVector::UpVector * EnemyKnockbackForce.Y, true, true);
}
/// Apply damage to the actor that was hit
UGameplayStatics::ApplyDamage(
actor,
DashDamage,
playerController,
this,
nullptr
);
}
}
The Harpoon Gun is the third of the three physics-based weapons available to the player and serves as the primary traversal tool in Skybooter. The weapon was designed around high-speed movement, momentum-based traversal, and environmental interaction, allowing players to rapidly navigate between ships and across large open spaces. The Harpoon Gun combines multiple movement systems into a single weapon, including swinging, reeling, enemy pulling, and tether-based momentum control. By attaching to both world geometry and dynamic actors, the weapon creates a highly versatile traversal system that also supports combat and environmental gameplay interactions. A major focus throughout the Harpoon Gun’s design was balancing physically inspired rope movement with responsive player control. Many of the systems intentionally prioritized gameplay feel and readability over strict physical realism in order to maintain fluid movement and fast-paced traversal during combat encounters.
The Harpoon Gun’s impact system was responsible for transitioning the weapon between its projectile, traversal, and enemy interaction states once a collision occurred. When the harpoon collides with a valid target, projectile movement is immediately stopped and the harpoon becomes anchored at the impact location. The initial rope length between the player and the anchor point is then calculated and stored so traversal systems such as swinging and reeling can begin operating using the correct tether distance.
Once attached, the harpoon updates both gameplay and animation states to reflect that the player is now tethered to an active anchor point. The harpoon can attach to both world geometry and dynamic actors, allowing the same system to support traversal mechanics as well as combat interactions.
/// Ignore further hits if already stuck, invalid actor, or hitting the owning player
if (bStuck || !OtherActor || OtherActor->GetUniqueID() == PlayerCharacter->GetUniqueID())
{
return;
}
/// Blueprint event for SFX
OnAttach();
/// Set player height variables
AttachedPlayerHeight = PlayerCharacter->GetActorLocation().Z;
PrevPlayerHeight = 10000000000;
CurrentPlayerHeight = PlayerCharacter->GetActorLocation().Z;
/// Stop projectile movement when the harpoon sticks
ProjectileMovement->StopMovementImmediately();
ProjectileMovement->Deactivate();
/// Store the initial rope length when the harpoon hits
CableLength = FVector::Distance(Hit.ImpactPoint, PlayerCharacter->GetActorLocation());
/// Snap harpoon to the impact point and mark as stuck
SetActorLocation(Hit.ImpactPoint);
bStuck = true;
PreviousAnchorLocation = GetActorLocation();
If the harpoon strikes an environmental gameplay object such as an explosive barrel, the system immediately triggers the object’s explosion behavior before reloading the weapon. When attaching to moving objects or enemies, collision on the harpoon itself is disabled and the harpoon actor becomes attached directly to the impacted component or skeletal mesh bone. This ensured the harpoon visually remained embedded in moving targets while still preserving accurate rope positioning during gameplay.
if (PlayerCharacter)
{
if (USkeletalMeshComponent* Mesh = PlayerCharacter->GetMesh())
{
if (UPlayerAnimation* Anim = Cast<UPlayerAnimation>(Mesh->GetAnimInstance()))
{
Anim->SetHarpoonAttached(true);
}
}
}
/// If hit an exploding barrel
if (OtherActor)
{
if (AExplodingBarrel* barrel = Cast<AExplodingBarrel>(OtherActor))
{
barrel->Explode();
HarpoonGun->Reload();
return;
}
}
if (OtherActor)
{
Collision->SetCollisionEnabled(ECollisionEnabled::NoCollision);
/// Attach harpoon to actor it hit
FTransform WorldTransform = GetActorTransform();
AttachToComponent(
OtherComp,
FAttachmentTransformRules::KeepWorldTransform,
Hit.BoneName);
SetActorTransform(WorldTransform);
}
When an enemy is hit, the harpoon transitions into a combat interaction state. The struck enemy is temporarily stunned to create an opening for follow-up attacks or repositioning, while damage is applied through Unreal’s gameplay damage system. If the attack is lethal, the harpoon automatically returns to the player rather than remaining attached to a defeated target.
/// Check if HitResult hit an enemy and apply damage
if (OtherActor && OtherActor->GetRootComponent()->GetCollisionObjectType() == ECC_Pawn)
{
bStuckToEnemy = true;
HarpoonedEnemy = Cast<AEnemyBase>(OtherActor);
HarpoonedEnemy->StunMe();
/// Return harpoon if the shot kills the enemy
if (HarpoonedEnemy->CurrentHealth - HarpoonGun->Damage <= 0)
{
ReturnToPlayer();
}
UGameplayStatics::ApplyDamage(
OtherActor,
HarpoonGun->Damage,
PlayerCharacter->GetController(),
this,
nullptr
);
}
Supporting both static and dynamic attachment targets was particularly important for the Harpoon Gun because it allowed the same underlying tether system to seamlessly power traversal, enemy manipulation, and environmental interactions while maintaining consistent gameplay behavior across all use cases.
The Harpoon Gun’s swing system was designed to serve as one of the primary traversal mechanics in Skybooter, allowing players to rapidly move between ships and maintain momentum through large open environments. The system simulates a rope constraint between the player and the harpoon anchor point while still preserving fast, responsive gameplay movement.
When the harpoon becomes embedded in world geometry, the player enters a swinging state where movement behavior changes depending on whether the player is grounded or airborne. While grounded, the system enforces the rope length by applying corrective velocity toward the anchor point whenever the player exceeds the cable’s maximum distance. This prevents the player from moving beyond the rope constraint while still allowing controlled ground movement around the anchor.
// If player is grounded and rope is stretched beyond its cable length,
// apply a pulling force toward the anchor to enforce rope constraint
if (!PlayerCharacterMovementComponent->IsFalling() && ToHarpoon.Size() > CableLength)
{
float mult = (ToHarpoon.Size() / CableLength);
if (mult > 1.1) { mult *= 2; }
PlayerCharacterMovementComponent->Velocity += ToHarpoonNormal * PlayerCharacter->GetMaxWalkSpeed() * 13.7f *
DeltaTime * mult;
}
During airborne swinging, the system separates the player’s velocity into radial and tangential components relative to the harpoon anchor. The radial component, which represents movement directly toward or away from the anchor point, is reduced in order to preserve the rope constraint. The remaining tangential velocity is then maintained to allow the player to continue swinging naturally around the anchor point. This approach helped create a smoother and more physically believable swinging motion while still remaining highly controllable during gameplay.
To further enhance traversal feel, an additional tangential speed boost is applied when the player first enters the swing state. This helped players immediately carry momentum into traversal sequences rather than losing speed when attaching to the harpoon. The system also accounts for moving anchor points by measuring frame-to-frame displacement of the harpoon location and applying corresponding movement forces to the player when necessary.
// Calculate the velocity component along the rope direction
FVector radialVel = FVector::DotProduct(PlayerCharacterMovementComponent->Velocity, ToHarpoonNormal) * ToHarpoonNormal;
// Remove radial component to keep only perpendicular motion
FVector tangentialVel = PlayerCharacterMovementComponent->Velocity - radialVel;
// On the first swing frame, boost tangential speed
if (bFirstSwing)
{
tangentialVel *= ExtraFirstSwingForce / tangentialVel.Size();
bFirstSwing = false;
}
// Compute frame-to-frame harpoon displacement
FVector HarpoonDelta = GetActorLocation() - PreviousAnchorLocation;
if (ToHarpoon.Size() > CableLength)
{
// Apply the pull
tangentialVel += -radialVel * DeltaTime * FMath::Min(60.f * ToHarpoon.Size() / CableLength, 60.f);
}
// Pull player into harpoon if the harpoon is moving away
if (!HarpoonDelta.IsNearlyZero(0.01f))
{
PlayerCharacter->LaunchCharacter(ToHarpoonNormal * (HarpoonDelta.Size() / DeltaTime) * 0.9f, true, true);
}
else
{
// Apply tangential velocity to maintain swinging motion
if (!tangentialVel.IsNearlyZero())
{
PlayerCharacterMovementComponent->Velocity = tangentialVel;
}
}
Because traversal speed and responsiveness were prioritized over strict realism, the swing system intentionally blended physically inspired motion with gameplay-driven velocity adjustments. This allowed the mechanic to feel fluid, readable, and highly controllable while still maintaining the visual identity of a rope-based swinging system.
The Harpoon Gun also features a reeling system that allows the player to rapidly close distance either by pulling themselves toward world geometry or by pulling enemies toward the player. This mechanic was designed to support both traversal and combat, giving the weapon a high degree of versatility during gameplay.
When the harpoon becomes embedded in world geometry, the player can enter a zip state that continuously launches the character toward the harpoon anchor point. The pull direction is calculated using the normalized vector between the player and the harpoon location, ensuring movement remains tightly aligned with the tether. As the player moves closer to the anchor, the cable length is dynamically updated to maintain smooth rope behavior and prevent visual desynchronization between the player and harpoon.
// Trigger pull event once when starting to reel player in
if (!bReelingPlayerInLastFrame)
{
OnPullPlayer();
bReelingPlayerInLastFrame = true;
}
// Launch player toward harpoon location
PlayerCharacter->LaunchCharacter(ToHarpoonNormal * ZipPullStrength * DeltaTime, true, true);
// Update cable length as player moves
CableLength = FVector::Distance(GetActorLocation(), PlayerCharacter->GetActorLocation());
In combat scenarios, the system could also attach directly to enemies. When this occurs, the harpoon transitions into an enemy pull mode that attempts to drag the target toward the player rather than moving the player toward the anchor point. The enemy is repositioned incrementally along the tether direction while collision handling is temporarily adjusted to prevent the harpoon from interfering with enemy movement during the pull.
Additional fallback movement logic was implemented for situations where full three-dimensional movement became blocked by surrounding geometry. In these cases, the pull system transitions to a horizontal-only movement solution to preserve responsiveness and prevent enemies from becoming stuck during traversal or combat interactions.
// Harpoon is stuck and attached to an enemy
if (bStuck && bStuckToEnemy)
{
// We are not pulling the player this frame
bReelingPlayerInLastFrame = false;
// If enemy is far enough and we are allowed to pull them
if (ToHarpoon.Size() > 100.f && bPullInEnemy)
{
// Prevent collision between harpoon and enemy while pulling
HarpoonedEnemy->GetCapsuleComponent()->IgnoreActorWhenMoving(this, true);
// Try pulling enemy directly toward player (full 3D direction)
if (!HarpoonedEnemy->SetActorLocation(
HarpoonedEnemy->GetActorLocation() - ToHarpoonNormal * EnemyPullStrength * DeltaTime, true))
{
// If blocked , fallback to horizontal-only pull
HarpoonedEnemy->SetActorLocation(
HarpoonedEnemy->GetActorLocation() - FVector(ToHarpoon.X, ToHarpoon.Y, 0.f).GetSafeNormal() *
EnemyPullStrength * DeltaTime, true);
}
}
// If enemy is still far but not yet pulling, enable pull mode
else if (ToHarpoon.Size() > 200.f)
{
bPullInEnemy = true;
}
// stop pulling enemy
else
{
bPullInEnemy = false;
}
}
As Programming Lead, I managed a team of three additional developers and was responsible for overseeing the technical direction and implementation workflow of the project. A major part of this role involved determining how systems should be architected between Unreal Engine’s C++ and Blueprint workflows, ensuring features were implemented in ways that balanced performance, scalability, and designer usability.
I coordinated development responsibilities across the programming team by dividing systems based on each developer’s primary workflow and technical strengths. This included organizing collaboration between developers primarily focused on C++ systems and those working more heavily in Blueprints, while also contributing extensively to gameplay programming myself.
In addition to development responsibilities, I handled code reviews for incoming features and changes to ensure systems were properly tested, maintainable, and free of issues before integration into the main project branch. I also managed source control workflows, including branch organization, pull requests, and resolving merge conflicts across the team. This became especially important during periods of rapid iteration where multiple gameplay systems were being developed simultaneously.
As Producer, I managed a team of 15 other developers and was responsible for coordinating production across the entire project. This included organizing weekly sprint tasks, maintaining detailed documentation and task backlogs, and tracking dependencies to ensure team members remained unblocked and development continued moving efficiently.
I was also responsible for making final decisions on difficult or controversial production choices. Because I assigned and tracked work across all disciplines, I maintained a broad understanding of the project’s overall state and was able to evaluate decisions from the perspectives of multiple team members and departments before determining the best path forward.
In addition to production management, I led team meetings, created and delivered project presentations, and coordinated presentation responsibilities when additional team members needed to speak. I also handled communication between the team, our professors, and our mentors from Bungie to ensure feedback and production goals remained aligned throughout development.
For project management and organization, I utilized GitHub Wiki for documentation and GitHub Projects for sprint and task management. Keeping code, documentation, and production tracking within the same repository streamlined collaboration and helped maintain a centralized workflow for the entire team.
This project was my first experience developing in Unreal Engine 5 after previously working primarily in Unity. One of my main goals with Skybooter was not only to strengthen my gameplay programming abilities, but also to challenge my ability to quickly learn and adapt to an entirely new engine and workflow while actively developing a large team project.
Learning Unreal while simultaneously making architectural and gameplay decisions was initially challenging, especially while balancing responsibilities as both Programming Lead and Producer. However, working through these challenges significantly improved my ability to rapidly learn new technologies, evaluate unfamiliar systems, and adapt workflows under production constraints.
One of the largest differences coming from Unity was Unreal’s integration of C++ with Blueprint visual scripting. Unlike Unity’s fully code-driven workflow, Unreal encouraged a hybrid development approach where systems could be divided between C++ implementation and designer-friendly Blueprint logic. Learning where functionality was best implemented in C++ versus Blueprints became an important part of the project and greatly improved both my technical workflow and collaboration with designers and artists once I became more familiar with the engine’s architecture.
One of the major challenges our team was given by our mentors from Bungie was to create a fully playable prototype containing all core gameplay systems within the first week of development. Initially, this felt extremely aggressive given the project scope and short timeline. However, this early milestone ultimately became one of the most important factors in shaping Skybooter’s gameplay quality.
Having a playable build so early allowed designers to begin testing mechanics and interacting with gameplay systems almost immediately after implementation. This led to significantly faster feedback cycles than I had experienced on previous projects, as designers were able to quickly identify where ideas were or were not working and communicate that feedback directly to me as the gameplay programmer.
Because core systems were implemented and validated early, I was able to spend far more time iterating and refining mechanics rather than rushing foundational systems near the end of production. This created a much stronger gameplay foundation and allowed additional development time for polish, tuning, and improving overall game feel.
After this experience, I strongly believe that developing an early playable prototype is one of the most valuable practices a gameplay-focused team can adopt. Rapid validation of core mechanics and gameplay direction greatly improves iteration speed, communication, and overall project quality.