Unreal 4 C++ – Multiplayer Stealth Game


A multiplayer first-person stealth prototype built in UE4 C++. The player must pick up an objective and reach the extraction zone without being spotted by AI guards. The full implementation is available as a YouTube playlist — this post covers the architectural decisions behind the networking and AI systems.

📺 Demo Video  |  📋 Full Playlist  |  🔗 GitHub Repository


The multiplayer architecture

The networking in this project uses three different RPC patterns, each chosen for a specific reason.

ServerFire — authoritative projectile spawning

When the player fires, Fire() runs on the owning client immediately for local audio and animation feedback. It then calls ServerFire(), an RPC decorated with Server, Reliable, WithValidation:

cpp

UFUNCTION(Server, Reliable, WithValidation)
void ServerFire();

The server executes ServerFire_Implementation(), spawns the projectile, and because AFPSProjectile has SetReplicates(true) and SetReplicateMovement(true), the projectile replicates to all clients automatically. Clients never spawn projectiles directly — the server is the single authority on what exists in the world.

WithValidation generates ServerFire_Validate(), which returns false to kick a client sending malformed or abusive RPC calls. Currently it always returns true, but the hook exists — adding rate-limit or position validation later requires no changes to the calling code.

NetMulticast — mission completion notification

When the mission ends (AI sees the player, or player extracts with the objective), AFPSGameMode::CompleteMission() is called. The game mode is server-only — it has no representation on clients. A NetMulticast RPC declared on the game mode would silently do nothing on clients because they have no game mode instance.

The correct home for server-to-all-clients communication is AGameStateBase, which replicates to all clients. AStealthGameState::MulticastOnMissionCompleted() is a NetMulticast, Reliable RPC that executes on every machine:

cpp

UFUNCTION(NetMulticast, Reliable)
void MulticastOnMissionCompleted(APawn* InstigatorPawn, bool bMissionCompleted);

Inside the implementation, the iterator over player controllers filters to IsLocalController() — each machine only touches its own locally-controlled controllers, leaving remote player representations alone.

ReplicatedUsing — AI state with client-side response

The AI guard’s behavioral state (Idle, Suspicious, Alerted) is replicated with a RepNotify:

cpp

UPROPERTY(ReplicatedUsing = OnRep_AIState)
EAIState AIState;

When the server changes AIState, it replicates to all clients. On arrival, OnRep_AIState() fires automatically, which calls OnAIStateChanged() — a BlueprintImplementableEvent that changes the guard’s material color or triggers animations. This pattern requires zero additional RPCs: one replicated property drives the full visual response on every client.

There is a subtlety: RepNotify callbacks only fire on clients, not on the server that set the value. SetAIState() calls OnRep_AIState() manually after changing the value so the server’s Blueprint event fires too.


Replicated objective state

AFPSCharacter::bIsCarryingObjective is a simple replicated bool:

cpp

UPROPERTY(Replicated, BlueprintReadOnly, Category = "Gameplay")
bool bIsCarryingObjective = false;

When the player overlaps AObjectiveActor, the server sets this to true and destroys the objective actor. Because the property is replicated, all clients immediately know whether the player is carrying the objective — which AExtractionZone checks server-side before calling CompleteMission().

AObjectiveActor itself uses SetReplicates(true) so it exists on all clients. Its NotifyActorBeginOverlap() runs PlayEffects() on every machine for immediate visual feedback, but gates the authoritative state change behind HasAuthority(). This is the standard split for cosmetic-vs-gameplay effects in UE4 multiplayer: cosmetics run everywhere for responsiveness, state changes run only on the server.


Remote view pitch decompression

UE4 compresses the camera pitch of remote pawns into a uint8 named RemoteViewPitch (range 0–255) to save bandwidth. For a third-person game this doesn’t matter — the camera pitch isn’t visible. For a first-person game it does: without correction, remote players’ weapon and head orientation appears locked at zero pitch regardless of where they’re aiming.

AFPSCharacter::Tick() corrects this on non-locally-controlled instances:

cpp

if (!IsLocallyControlled())
{
FRotator NewRotator = CameraComponent->GetRelativeRotation();
NewRotator.Pitch = RemoteViewPitch * 360.0f / 255.0f;
CameraComponent->SetRelativeRotation(NewRotator);
}

The formula RemoteViewPitch * 360.0f / 255.0f inverts the compression that UCharacterMovementComponent applied when encoding the value. On the locally-controlled client the camera pitch is driven by actual input, so the correction is skipped.


AI guard: sight, hearing, and patrol

The guard uses UPawnSensingComponent with two callbacks bound in the constructor:

cpp

PawnSensing->OnSeePawn.AddDynamic(this, &AAIGuard::OnSeePawn);
PawnSensing->OnHearNoise.AddDynamic(this, &AAIGuard::OnHearNoise);

OnSeePawn() immediately calls CompleteMission(false) and transitions to Alerted. Once alerted, the state cannot be downgraded — OnHearNoise() early-returns if AIState == EAIState::Alerted.

OnHearNoise() rotates the guard toward the noise source (zeroing pitch and roll to keep the character upright), starts a 3-second timer, and transitions to Suspicious. Each subsequent noise resets the timer — the guard stays suspicious as long as noises keep occurring. On timer expiry, ResetRotation() returns to Idle and resumes patrol if configured.

Patrol uses UAIBlueprintHelperLibrary::SimpleMoveToActor() between two designer-placed AActor waypoints. Tick() checks proximity (< 50 cm) to the current waypoint and calls MoveToNextPatrolPoint() when close enough. The 50 cm threshold prevents the guard from oscillating around a point it can’t reach exactly due to pathfinding precision.

For the noise system to work, AFPSCharacter needs a UPawnNoiseEmitterComponent. Without it, MakeNoise() calls from AFPSProjectile::OnHit() produce events that UPawnSensingComponent ignores — the guards become permanently deaf. The component is created in AFPSCharacter‘s constructor and is easy to miss.


Mission completion flow end-to-end

The full flow when the player is spotted:

  1. UPawnSensingComponent fires OnSeePawn() on the server (AI runs server-only).
  2. AAIGuard::OnSeePawn() calls AFPSGameMode::CompleteMission(pawn, false).
  3. CompleteMission() iterates all player controllers and switches their view target to the spectating actor via SetViewTargetWithBlend().
  4. CompleteMission() calls AStealthGameState::MulticastOnMissionCompleted().
  5. The NetMulticast RPC executes on server + all clients.
  6. Each machine iterates its player controllers, finds locally-controlled ones, calls AStealthPlayerController::OnMissionCompleted() (Blueprint shows the fail screen), and calls DisableInput() on the controlled pawn.

The spectating viewpoint is found at runtime via UGameplayStatics::GetAllActorsOfClass() rather than a direct reference, so level designers can place or replace it without modifying the game mode Blueprint.


Engine version: Unreal Engine 4

Leave a comment

Create a website or blog at WordPress.com

Up ↑