A third-person obstacle course prototype built in UE5 C++. The goal is simple: reach the end of the level without falling off or getting hit. The implementation uses four custom C++ classes — a moving platform, two trigger zones, and two UI widgets — with some decisions worth explaining.
📺 Demo Video | 🔗 GitHub Repository | 📖 Learning Source
Moving platform: drift-free oscillation
AMovingPlatform combines linear back-and-forth movement with optional continuous rotation. Both are driven from Tick() independently, so a platform can translate, rotate, or do both simultaneously without either affecting the other.
The linear movement uses a reversal pattern that avoids a subtle but real problem. The naive approach — check the distance each frame, flip velocity when it exceeds the limit — accumulates floating-point error over time. SetActorLocation() positions carry small per-frame imprecision, so the measured distance between StartLocation and the current position drifts slightly each cycle. After enough oscillations the reversal point shifts noticeably and platforms no longer travel symmetric distances.
The fix is to snap StartLocation forward before inverting velocity:
cpp
StartLocation += Velocity.GetSafeNormal() * MaxMovementDistance;SetActorLocation(StartLocation);Velocity *= -1.0f;
This resets the distance reference to a clean, known position every cycle. Any accumulated error is discarded at each reversal rather than compounding indefinitely.
Rotation uses AddActorLocalRotation(AngularVelocity * DeltaTime) rather than world-space rotation. Local space means a tilted platform spins around its own axes — a platform angled 45° rotates in the way a player would expect, not around the world up-vector. AngularVelocity is a designer-facing FRotator property, so any combination of pitch, yaw, and roll is configurable per platform.
Trigger zones: tag-based detection
AGoalZone and AKillZone are structurally identical: a UBoxComponent root, an overlap callback, and a widget that displays on trigger. Both check OtherActor->ActorHasTag("Player") to identify the player rather than casting to a specific character class.
The tag approach keeps both zones decoupled from the character implementation. A Cast<ACharacter> check would compile a dependency on a specific class into every zone actor — if the character is replaced, renamed, or tested with a placeholder pawn, every zone needs updating. The tag is just a string on the actor, settable from Blueprint or C++ on any actor type. The zones don’t care what the player is, only whether it’s tagged correctly.
Widget lifecycle: create at BeginPlay, not on overlap
Both zones create their widget in BeginPlay() and add it to the viewport immediately:
cpp
void AGoalZone::BeginPlay(){ Super::BeginPlay(); CreateAndShowWinWidget(); BoxCollision->OnComponentBeginOverlap.AddDynamic(this, &ThisClass::OnBeginOverlap);}
The widget starts invisible — the Blueprint animation handles the reveal. Creating it on overlap instead would introduce a one-frame gap between the collision event and the widget appearing, which is visible as a flicker on slower machines. More importantly, creating on overlap means the Blueprint subclass’s BeginPlay initialization (binding buttons, setting up animations) hasn’t finished running yet when the event fires, leading to null reference crashes in the Blueprint graph.
Creating at BeginPlay gives the widget its full initialization window before any overlap could possibly occur.
UI contract: BlueprintImplementableEvent
UWinWidget and UDeathWidget each declare a single BlueprintImplementableEvent:
cpp
UFUNCTION(BlueprintImplementableEvent, Category = "Game")void OnGoalZoneReached();
C++ defines when the event fires and what triggers it. Blueprint implements what happens — the fade animation, sound cue, restart button binding, text display. This split keeps the C++ layer stable while leaving UI iteration entirely in Blueprint. A designer can completely redesign the death screen without touching a single line of C++, and the zone actors don’t need recompilation.
The alternative — BlueprintNativeEvent — would be appropriate if there were also a C++ default implementation to fall back on. Here there is no meaningful C++ behavior for “show the death screen,” so BlueprintImplementableEvent is the right choice: it signals clearly that the implementation is expected to be in Blueprint, and calling the function when no Blueprint override exists simply does nothing rather than crashing.
Build.cs: the missing UMG dependency
The original course code omitted "UMG" from the Build.cs dependencies. UUserWidget lives in the UMG module, so without it the project will fail to link in a clean build — it only worked in the course environment because UMG was being pulled in transitively by other modules that happened to be loaded. Added explicitly to make the dependency graph honest.
Engine version: Unreal Engine 5.0
Leave a comment