📺 Video demo on YouTube · 💻 Source code on GitHub
Virus Killer is a top-down shooter prototype built in Unreal Engine 4 C++ where you control a cell fighting virus enemies across an open arena. The premise is simple, but the mechanic underneath it is what made this project interesting to build: your visual size is your health bar, and firing your weapon costs health.
Based on the Unreal Engine Top-Down Shooter course on Udemy.
Scale as Health
There is no health integer in this project. APlayerPawn derives its health budget from the actor’s world scale at spawn time — MaxScale = GetActorScale3D().Size() — and divides it into 10 increments. Every health event adds or subtracts one of those increments:
- Firing a projectile costs one increment. Shooting literally shrinks you.
- Taking a hit from an enemy costs one increment.
- Collecting a health pickup restores one increment, clamped to MaxScale.
- Death is when
CurrentScale <= 0.0f.
The actor’s visual scale interpolates toward CurrentScale each tick using FInterpTo, so hits feel physical rather than numerical — the cell visibly contracts. The same pattern applies to enemies: MaxScale is their health too, and they visually grow toward it on spawn, making large enemies immediately readable as threats with more life.
This design eliminates a whole class of synchronization bugs. There is no health integer to keep in sync with a scale float — the float is both the state and the display.
Aiming and the Cost of Shooting
Aiming works via a screen-to-world visibility raycast each tick. GetHitResultUnderCursor returns the world-space impact point under the mouse, and FindLookAtRotation rotates a UArrowComponent to face it. Projectiles spawn at the arrow’s tip and travel along its local X axis via AddActorLocalOffset each frame — no UProjectileMovementComponent needed for a flat top-down game.
The interesting constraint is that CanSpawnProjectile() enforces three conditions simultaneously: the fire button must be held, the fire-rate cooldown must have expired, and firing must not reduce CurrentScale to zero or below. That third condition means the player can never shoot themselves to death — but when they get close to that threshold, the system spawns a batch of health pickups around them as a compensatory measure, keeping the game recoverable rather than trapping the player in a no-fire state.
Enemy Behavior and the Split Mechanic
AVirusEnemy initializes with 100 pre-generated random patrol points in a 5000-unit XY field and wanders between them using VInterpTo. Speed and rotation rate are randomized per-instance in BeginPlay so enemies never move uniformly. Each enemy always dies in exactly two hits regardless of size — ScaleReductionFactor is set to MaxScale * 0.5f in BeginPlay, which guarantees this property holds for any starting scale.
On death, 5 new enemies spawn at the dead enemy’s location. This creates an exponential population dynamic: killing one large enemy produces 5 smaller ones, each of which will produce 5 more. The arena stays populated without any spawner actor or wave manager — the enemies generate their own population. A health pickup drops on every hit (damage and death) so the player always has recovery options proportional to the fight’s intensity.
HUD Architecture
UPlayerHUD and UGameOverHUD are both UUserWidget subclasses created and owned by APlayerPawn. The split is clean: APlayerPawn::CreateAndShowPlayerHUD() runs in BeginPlay, and CreateAndShowGameOverHUD() runs the moment IsDead() returns true after a damage event.
UPlayerHUD exposes one BlueprintImplementableEvent: PlayDamageAnimation(). C++ defines the contract — this must happen when the player takes a hit — and the Blueprint subclass implements the visual (a screen flash, a shake, a color shift). This keeps animation timing and easing entirely in the designer’s hands without requiring any additional C++ changes if the effect needs to change.
UGameOverHUD is a pure typed contract in C++. Its Blueprint subclass handles the restart button, any score display, and whatever transition animation is appropriate. APlayerPawn only needs the type to call CreateWidget<UGameOverHUD>() — it never calls any methods on it directly.
What I’d Extend Next
The split mechanic has a depth problem: spawned enemies are always the same Blueprint subclass as the parent, so they have the same starting scale. A natural extension would be to expose ChildEnemyClass as a separate TSubclassOf<AVirusEnemy> property, distinct from EnemyClass, so splits produce a visibly different and smaller enemy type.
The aiming arrow is always visible in-game (bHiddenInGame = false). For a polished build it would be replaced with a particle effect or a subtle directional indicator that doesn’t break the biological aesthetic of the cell theme.
Leave a comment