Unreal 5.2 C++ – Space Shooter


This project is a SHMUP (shoot ’em up) prototype built in UE5.2 C++. The original course was designed for UE4 and Blueprint. My implementation translates it entirely to C++, updates it to UE5.2 conventions including Enhanced Input, and applies the same code quality standards I hold any production codebase to.

📺 Demo Video  |  🔗 GitHub Repository  |  📖 Learning Source


Architecture overview

The project is organized around a shared ship base class, three reusable actor components, and a wave management system. Rather than duplicating logic between the player and enemy ships, ABaseShip provides the common infrastructure and each subclass extends only what differs.

ASpaceShooterGameMode
└── AWaveManager — wave state machine
ABaseShip (APawn)
├── APlayerPawn — Enhanced Input, interpolated movement, HUD
└── ABaseEnemy — oscillation AI, ramming, wave-aware death
ABaseProjectile (AActor)
└── ARocketProjectile — homing via overlap + per-tick velocity lerp
APickup (AActor) — two-box homing + contact trigger
Components:
UHealthComponent — health, damage, passive regen
UProjectileHandlerComponent — three fire modes, timer-gated cooldown
UPickupHandlerComponent — probability drop, random class selection

The ship hierarchy

ABaseShip is an abstract APawn that provides: a UBoxComponent root for collision, a UStaticMeshComponent with collision disabled, UHealthComponent, UProjectileHandlerComponent, a ProjectileSpawnPoint, and the full damage/death pipeline including particle effects and camera shake routing.

The key design choice is that TakeDamage() lives in ABaseShip and handles everything: it delegates to UHealthComponent::ReceiveDamage(), calls CollisionCameraShake() on survivable hits and ExplosionCameraShake() on lethal ones, and calls Die() which subclasses extend. Neither APlayerPawn nor ABaseEnemy re-implement the damage flow — they only override Die() to add their specific behavior (player destroys self; enemy notifies the game mode, spawns a pickup, then destroys self).

APlayerPawn extends this with Enhanced Input bindings, two-axis interpolated movement via FMath::VInterpTo, mesh pitch tilt during vertical movement, and HUD integration. ABaseEnemy adds randomized vertical oscillation, wall bounce on OnComponentHit, and ramming damage on OnBoxCollisionBeginOverlap.


Movement and input

Player movement uses FMath::VInterpTo rather than direct location setting. HandleMoveAction() updates two separate target vectors — one for vertical (Z) and one for horizontal (Y) — each frame the input is held. UpdateActorLocation() then interpolates toward each target independently, with SetActorLocation(bSweep=true) to respect the level boundary walls. The result is smooth, frame-rate-independent movement with natural deceleration when input is released.

The mesh pitch tilt works the same way: HandleMoveAction() stores the intended vertical speed as TargetMeshPitch, UpdateMeshRotation() interpolates the mesh’s relative rotation toward it via RInterpTo, and resets TargetMeshPitch to zero after each frame. When no vertical input is active, the mesh smoothly returns to neutral.

Input is handled via Enhanced Input with UInputMappingContext and three UInputAction assets: Move (2D axis, ETriggerEvent::Triggered), PrimaryFire (Triggered for continuous fire), and RocketFire (Started for single salvo).


Projectile system

UProjectileHandlerComponent manages all three fire modes with a single FTimerHandle cooldown gate. After any fire call, bCanFire is set to false and a timer re-enables it after the mode-specific rate. This is frame-rate-independent and handles mode-switching during cooldown automatically — if the player switches from primary to rocket mid-cooldown, the timer still fires correctly.

Primary fire adds small random Z-offset and roll variation per shot to create visual spread without requiring separate spawn points. The array overload allows multi-point enemies to fire from both spawn points in a single call.

Powered-up fire spawns PoweredUpProjectilesCount projectiles in a symmetric fan. The offset calculation starts at -(Count / 2) and increments by 1 per projectile, with each step adding 5 units of Z and 5 degrees of roll — producing a clean spread centered on the spawn point.

Rocket fire always spawns exactly two rockets at fixed offsets of -2 and +2. Each rocket is an ARocketProjectile that extends ABaseProjectile with a large HomingBox (1000×1500×1500 units). The first ABaseEnemy to enter the box is locked as the homing target, and each tick UpdateHomingVelocity() lerps ProjectileMovement->Velocity toward the target at HomingSpeed. The lerp factor of 0.1 produces a curved arc rather than an instant snap, which gives enemies implicit reaction time.

All spawned projectiles immediately call SetOwner(GetOwner()) so that ABaseProjectile::IsOwnedByPlayer() can correctly route collision damage: player projectile overlapping an enemy → damage enemy; enemy projectile overlapping the player → damage player + camera shake.


Pickup homing

APickup uses two separate UBoxComponent volumes for a reason: the large HomingBox (2000 units on each side) detects the player at range and locks onto them as the homing target; the small ItemBox handles the actual contact that triggers the buff. The ItemBox callback verifies HomingPlayer == OtherActor before applying any effect — this prevents accidental collection by enemies or other actors that might overlap the small contact box.

Before homing lock-on, the pickup scrolls along the Y axis at DefaultSpeed via UProjectileMovementComponent. After lock-on, each tick lerps the velocity toward the player at HomingSpeed. URotatingMovementComponent provides a constant spin independently of the translation.

Three pickup types are implemented as an EPickupType enum: powered-up spread shot, rocket ammo (consumed on the next rocket salvo), and health recovery (calls UHealthComponent::Recover(50.0f) directly).


Wave management

AWaveManager runs a simple state machine in Tick(). On BeginPlay() a timer fires SpawnNewWave() after 3 seconds. Each wave spawns between MinEnemiesPerWave and MaxEnemiesPerWave enemies at random locations along a line between two ATargetPoint actors. ASpaceShooterGameMode maintains an AliveEnemyCount counter — incremented on spawn, decremented on destruction — and AWaveManager::OnEnemyDestruction() checks whether CurrentWaveEnemyCount has reached zero to advance to the next wave. After all waves are cleared, a boss spawns at the midpoint of the spawn line.


Bug fixes

The most impactful bug was in UHealthComponent::RecoverHealth(). The original code assigned the result of RecoveryRate * DeltaTime to an int32:

cpp

const int32 HealthOffset = RecoveryRate * DeltaTime;

For any RecoveryRate less than roughly 60 (i.e. less than 1.0 / DeltaTime at 60fps), this expression evaluates to a float smaller than 1.0, which the int32 assignment truncates to 0. The passive health regen was silently doing nothing for any reasonable regen value. Fixed by changing the type to float.

The other notable fix was renaming CanFire to bCanFire in UProjectileHandlerComponent. The original didn’t follow UE’s boolean naming convention — minor, but it matters for consistency when the codebase grows and other developers read it.

Beyond those: LogTemp replaced with a custom LogSpaceShooter category throughout, raw UPROPERTY pointers upgraded to TObjectPtr<T>, and check() replaced with ensureMsgf() in recoverable validation paths (so a missing asset in a drop-ship configuration produces a log message rather than a hard crash in development).


Engine version: Unreal Engine 5.2

Leave a comment

Create a website or blog at WordPress.com

Up ↑