Building a Multiplayer TPS Game in Unreal Engine 5
This blog introduces the architecture and implementation of a multiplayer Third-Person Shooter (TPS) game demo built with Unreal Engine 5.4 (UE5). The project covers custom character movement, a networked combat system, projectile weapons with server-side lag compensation, and a dynamic HUD — all implemented in C++.
GitHub Repo: EricY019/TPSDemo
Project Overview
The demo supports the following player interactions:
| Action | Input |
|---|---|
| Move | WASD |
| Jump | Space |
| Sprint | Left Shift (hold) |
| Slide | X |
| Aim | Mouse Right Button (hold) |
| Fire | Mouse Left Button |
| Equip Weapon | E (when overlapping weapon) |
The game is fully multiplayer-ready using Unreal Engine’s built-in replication framework.
Architecture
The codebase is organized into the following major classes:
ADemoCharacter - Main player character (ACharacter)
├── UCombatComponent - Handles weapon equipping, aiming, firing
└── UDemoCharacterMovementComponent - Custom movement (sprint, slide)
AWeapon - Base weapon actor
└── AProjectileWeapon - Projectile-based weapon (extends AWeapon)
└── AProjectile - Individual projectile actor
ADemoHUD - HUD (draws dynamic crosshairs)
ADemoGameMode - Handles player elimination and respawn
ADemoPlayerController - Player controller (updates HUD health bar)
Custom Character Movement: Sprint & Slide
One of the more interesting engineering challenges in multiplayer games is implementing custom movement modes that work correctly with Unreal’s client-side prediction system.
Why Custom Movement Needs Special Treatment
In a networked game, the server is authoritative. UE5’s CharacterMovementComponent uses a saved move mechanism: the client records each frame’s move, sends it to the server, and the server re-simulates it. If any custom state (e.g., “is sprinting?”) is not packed into the saved move, the client and server will desynchronize.
The FSavedMove_Demo Pattern
DemoCharacterMovementComponent extends UCharacterMovementComponent and introduces a nested FSavedMove_Demo:
class FSavedMove_Demo : public FSavedMove_Character
{
public:
uint8 Saved_bWantsToSprint:1;
uint8 Saved_bWantsToSlide:1;
virtual uint8 GetCompressedFlags() const override;
virtual void SetMoveFor(...) override; // safe -> saved
virtual void PrepMoveFor(...) override; // saved -> safe
};
The two boolean states (Safe_bWantsToSprint, Safe_bWantsToSlide) are packed into FLAG_Custom_0 and FLAG_Custom_1 of the compressed move flags, which are then transmitted to the server each tick.
uint8 UDemoCharacterMovementComponent::FSavedMove_Demo::GetCompressedFlags() const
{
uint8 Result = Super::GetCompressedFlags();
if (Saved_bWantsToSprint) Result |= FLAG_Custom_0;
if (Saved_bWantsToSlide) Result |= FLAG_Custom_1;
return Result;
}
On the server, UpdateFromCompressedFlags reconstructs the safe booleans from the received flags, keeping server state synchronized with the client.
Sprint
Sprint is a simple MaxWalkSpeed override. Inside OnMovementUpdated (called every move tick):
if (Safe_bWantsToSprint)
MaxWalkSpeed = Sprint_MaxWalkSpeed;
else
MaxWalkSpeed = Walk_MaxWalkSpeed;
Slide
Slide is implemented as a custom movement mode (CMOVE_Slide). Entering slide requires the player to have sufficient speed and be on a valid surface (checked with a downward line trace). The physics loop PhysSlide runs each tick while sliding:
- Surface gravity — applies a downward force to keep the player hugging slopes.
- Strafe — projects acceleration onto the character’s right vector, allowing side-to-side control during slides.
- Capsule shrink — reduces the capsule half-height from 88 to 44 units to simulate crouching.
- Exit condition — automatically exits slide when speed drops below
Slide_MinSpeedor the character leaves the surface.
void UDemoCharacterMovementComponent::PhysSlide(float DeltaTime, int32 Iterations)
{
// Surface gravity: v += a * dt
Velocity += Slide_GravityForce * FVector::DownVector * DeltaTime;
// Rotate capsule to face velocity direction
FQuat NewRotation = FRotationMatrix::MakeFromXZ(
Velocity.GetSafeNormal2D(), FVector::UpVector).ToQuat();
SafeMoveUpdatedComponent(Adjusted, NewRotation, true, Hit);
// ...
}
Combat System
The UCombatComponent is attached to ADemoCharacter and manages the entire weapon lifecycle.
Weapon Equipping
Weapons are AWeapon actors placed in the world. When the player overlaps one, the server sets OverlappingWeapon on the character (replicated to owner-only). Pressing Equip calls:
- Server →
Combat->EquipWeapon(OverlappingWeapon)directly - Client →
ServerEquipButtonPressed()RPC → server executesEquipWeapon
On equip, the weapon is attached to the character’s RightHandSocket via USkeletalMeshSocket::AttachActor.
Aiming & FOV Zoom
Aiming sets bAiming = true on the combat component (replicated). InterpFOV smoothly interpolates the camera’s field of view between the default FOV and the weapon’s ZoomedFOV each tick, giving a scope-like zoom effect.
Dynamic Crosshairs
The HUD displays five-texture crosshairs (center, left, right, top, bottom). The spread of the crosshairs is calculated dynamically from several factors:
CrosshairSpread = 0.5 (baseline)
+ CrosshairVelocityFactor (widens when running)
+ CrosshairInAirFactor (widens when airborne)
- CrosshairAimFactor (narrows when aiming)
+ CrosshairShootFactor (spikes on fire, then interpolates to 0)
When the crosshair trace hits an actor that implements IInteractWithCrosshairsInterface, the crosshair color turns red — providing visual feedback that an enemy is targeted.
Aim Offset & Turning in Place
ADemoCharacter computes Aim Offset angles (AO_Yaw, AO_Pitch) each frame to blend upper-body aiming animations:
- When standing still,
AO_Yawaccumulates. Once it exceeds ±90°,TurnInPlacesmoothly interpolates the yaw back to zero usingFInterpTo, playing a turning animation via theETurningInPlaceenum. - When running or jumping,
AO_Yawis zeroed andbOrientRotationToMovementtakes over. - For simulated proxies (remote players), turning is detected by comparing the actor’s rotation between frames against
TurnThreshold.
A known UE5 quirk: pitch values in [-90°, 0°) are packed as [270°, 360°) when replicated. The code remaps this range back on the receiving end:
if (AO_Pitch > 90.f && !IsLocallyControlled())
{
FVector2D InRange(270.f, 360.f);
FVector2D OutRange(-90.f, 0.f);
AO_Pitch = FMath::GetMappedRangeValueClamped(InRange, OutRange, AO_Pitch);
}
Projectile Weapons & Lag Compensation
The most technically involved feature is server-side hit validation for projectiles, which addresses the classic multiplayer problem: the client fires at where the enemy was (due to network latency), while the server only sees where the enemy is now.
Position History
Every character maintains a position history buffer (TArray<FPositionHistoryEntry>) that stores past positions with timestamps, kept for up to 1.5 seconds. Clients call ServerUpdatePosition() each tick to push their position to the server:
void ADemoCharacter::ServerUpdatePosition_Implementation(
const FVector& NewPosition, float TimeStamp)
{
PositionHistory.Add(FPositionHistoryEntry(NewPosition, TimeStamp));
// Remove entries older than MaxHistoryDuration (1.5s)
}
GetPositionAtTime(float ServerTime) linearly interpolates between history entries to reconstruct the character’s position at any past timestamp.
Projectile Hit Validation
When a projectile hits something on the client, AProjectile::OnHit fires. It calls AProjectileWeapon::OnHitEvent, which calls the server RPC ServerOnHitEvent:
Client: Projectile::OnHit
→ ProjectileWeapon::OnHitEvent (passes timestamp, location, spawn location)
Server: ServerOnHitEvent_Implementation
→ Reconstruct past position of hit character
→ Validate distance from bullet trajectory
→ Apply damage if valid
→ Multicast hit effects
The server reconstructs the character’s past position using the timestamp from the client, then computes how far that past position was from the bullet’s trajectory line. If the distance exceeds MarginDistance (400 units), the hit is rejected:
// Projection of (Character → SpawnLoc) onto shoot direction
FVector ProjectionPoint = SpawnLocation + ShootDirection * ProjectionLength;
float Distance = FVector::Distance(CharacterLocation, ProjectionPoint);
if (Distance > MarginDistance) return; // reject hit
UGameplayStatics::ApplyDamage(...);
This approach grants the benefit of client-side hit detection (responsive feel) while still validating legitimacy on the server.
Player Elimination & Respawn
When a player’s Health reaches zero, the DemoGameMode calls PlayerEliminated, which triggers:
ADemoCharacter::Elim()on the server — drops the equipped weapon.MulticastElim()— plays the elimination montage, disables input and collision on all clients.- After
ElimDelay(0.9 s),ElimTimerFinished()requests a respawn viaDemoGameMode::RequestRespawn. - The game mode picks a random
APlayerStartin the world and callsRestartPlayerAtPlayerStart.
Summary
This TPS demo demonstrates several important game development patterns in Unreal Engine 5:
- Custom CMC with client prediction for lag-free sprint and slide
- Actor component pattern (
UCombatComponent) to keep character class lean - Replication-driven state management for weapons, health, and movement
- Lag compensation via position history for fair projectile hit detection
These patterns form the backbone of production multiplayer shooters and serve as a solid foundation for more advanced gameplay systems.