UE5 C++ Best Practices: Modern Patterns for Game Development
Writing C++ for Unreal isn't like writing standard C++. Here's what you need to know to write robust, performant game code.
UE5 Is Not Standard C++
Unreal Engine has its own idioms, memory model, and coding standards. Code that's correct in standard C++ can be broken in UE. This guide covers the differences that matter.
Memory Management: The Garbage Collector
UObjects are garbage collected. This changes everything about memory management.
Rules for UObject Pointers
- Always use UPROPERTY(): Raw UObject* without UPROPERTY can become dangling after GC
- Check IsValid(): Before dereferencing any UObject pointer
- TWeakObjectPtr: For references you don't want to prevent GC
- TObjectPtr: The modern replacement for raw pointers in UE5
// BAD: Can become dangling
AActor* MyActor;
// GOOD: GC-aware
UPROPERTY()
TObjectPtr<AActor> MyActor;
// For optional references
TWeakObjectPtr<AActor> WeakActor;Non-UObject Memory
For non-UObject types, standard C++ rules apply with some additions:
- TSharedPtr/TWeakPtr: UE's smart pointers for non-UObject types
- TUniquePtr: Exclusive ownership, no overhead
- Avoid new/delete: Use MakeShared, MakeUnique, or placement new
UPROPERTY: More Than Just Reflection
UPROPERTY isn't optional decoration. It affects:
- Garbage collection (prevents premature destruction)
- Serialization (saving/loading)
- Replication (networking)
- Blueprint exposure
- Editor visibility
Essential Specifiers
// Blueprint read/write
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float Health;
// Blueprint read-only
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
UStaticMeshComponent* Mesh;
// Replicated
UPROPERTY(Replicated)
int32 Score;
// Replicated with notification
UPROPERTY(ReplicatedUsing=OnRep_Health)
float Health;Categories and Display Names
UPROPERTY(EditAnywhere, Category="Combat|Damage")
float BaseDamage;
UPROPERTY(EditAnywhere, meta=(DisplayName="Attack Speed"))
float AttackRate;UFUNCTION Patterns
Blueprint Integration
// Callable from Blueprint
UFUNCTION(BlueprintCallable, Category="Combat")
void TakeDamage(float Amount);
// Implementable in Blueprint
UFUNCTION(BlueprintImplementableEvent)
void OnDeath();
// C++ implementation, overridable in Blueprint
UFUNCTION(BlueprintNativeEvent)
void OnHit();
void OnHit_Implementation();Networking
// Server only
UFUNCTION(Server, Reliable)
void ServerFire();
// All clients
UFUNCTION(NetMulticast, Unreliable)
void MulticastPlaySound();
// Owning client only
UFUNCTION(Client, Reliable)
void ClientShowUI();Construction and Initialization
Constructor vs BeginPlay
The constructor runs when the CDO (Class Default Object) is created and for every instance. BeginPlay runs when the actor enters the world.
// Constructor: set defaults, create components
AMyActor::AMyActor()
{PrimaryActorTick.bCanEverTick = true; Mesh = CreateDefaultSubobject<UStaticMeshComponent>
(TEXT("Mesh")); RootComponent = Mesh;}
// BeginPlay: initialize runtime state
void AMyActor::BeginPlay()
{Super::BeginPlay(); Health = MaxHealth; FindEnemies();}Component Creation
- CreateDefaultSubobject: In constructor only, creates component with CDO
- NewObject: Runtime object creation
- CreateComponent: Runtime component creation on actors
Performance Patterns
Avoid Tick When Possible
// In constructor
PrimaryActorTick.bCanEverTick = false;
// Or reduce frequency
PrimaryActorTick.TickInterval = 0.1f; // 10HzPrefer timers, events, or delegates over continuous polling in Tick.
Cache References
// BAD: Called every frame
void Tick(float DeltaTime)
{GetWorld()->GetFirstPlayerController()->...}
// GOOD: Cached in BeginPlay
UPROPERTY()
TObjectPtr<APlayerController> CachedPC;
void BeginPlay()
{CachedPC = GetWorld()->GetFirstPlayerController();}Use FORCEINLINE Sparingly
FORCEINLINE is useful for trivial accessors. Don't use it for anything with significant logic—it increases code size and can hurt cache performance.
// Good use
FORCEINLINE float GetHealth() const { return Health; }
// Bad use - function is too complex
FORCEINLINE void ProcessDamage(...) { /* 50 lines */ }Collections
TArray
The most common collection. Dynamic array similar to std::vector.
TArray<AActor*> Enemies;
// Add elements
Enemies.Add(Enemy);
Enemies.AddUnique(Enemy);
// Remove
Enemies.Remove(Enemy);
Enemies.RemoveAt(Index);
// Find
int32 Index = Enemies.Find(Enemy);
AActor* Found = *Enemies.FindByPredicate([](auto* E){
return E->GetName() == "Boss";
});TMap
Key-value pairs. O(1) lookup.
TMap<FName, int32> Inventory;
Inventory.Add(TEXT("Sword"), 1);
int32* Count = Inventory.Find(TEXT("Sword"));
for (auto& Pair : Inventory)
{UE_LOG(LogTemp, Log, TEXT("%s: %d"), *Pair.Key.ToString(), Pair.Value);}TSet
Unique elements. O(1) membership check.
TSet<AActor*> ActiveActors;
ActiveActors.Add(Actor);
bool bIsActive = ActiveActors.Contains(Actor);String Handling
UE has three string types:
- FString: Mutable, general purpose (like std::string)
- FName: Immutable, case-insensitive, fast comparison (for identifiers)
- FText: Localization-ready (for UI text)
FString Str = TEXT("Hello");
FName Name = TEXT("ActorName");
FText Display = NSLOCTEXT("Game", "Greeting", "Hello!");
// Conversions
FString FromName = Name.ToString();
FName FromString = FName(*Str);Error Handling
Check Macros
// Fatal in all builds
check(Pointer != nullptr);
// Fatal in debug/development only
checkSlow(ExpensiveValidation());
// Non-fatal, logs error
ensure(Pointer != nullptr);
// With message
checkf(Index < Array.Num(), TEXT("Index %d out of bounds"), Index);Verify vs Check
verify evaluates the expression in all builds but only asserts in debug.check evaluates and asserts in debug, skips entirely in shipping.
// Side effect happens in all builds
verify(ImportantFunction());
// Side effect skipped in shipping
check(ImportantFunction()); // DON'T DO THISCommon Pitfalls
1. Forgetting Super Calls
void AMyActor::BeginPlay()
{Super::BeginPlay(); // Don't forget! // Your code...}2. Accessing World in Constructor
// BAD: World doesn't exist in constructor
AMyActor::AMyActor()
{GetWorld()->SpawnActor(...); // Crash!}3. Modifying CDO
// BAD: Modifies all instances
AMyActor::AMyActor()
{GetClass()->GetDefaultObject()->Health = 100;}4. Circular Dependencies
Use forward declarations in headers, include in .cpp files.
// In header
class AEnemy; // Forward declare
// In cpp
#include "Enemy.h"5. Hot Reload Issues
Adding UPROPERTY to existing classes requires full rebuild. Hot reload can corrupt instances. When in doubt, restart editor.
Code Style
Follow Epic's coding standard for consistency:
- PascalCase for types and functions
- Prefix: A (Actor), U (UObject), F (struct), I (interface), T (template), E (enum)
- Boolean prefix: b (bIsValid, bCanJump)
- Tabs for indentation
- Braces on new lines
class MYGAME_API AMyCharacter : public ACharacter
{GENERATED_BODY() public: AMyCharacter(); UPROPERTY(EditAnywhere, BlueprintReadWrite) float MaxHealth = 100.0f; UFUNCTION(BlueprintCallable) void TakeDamage(float Amount); private: UPROPERTY() float CurrentHealth;}