6 minute read

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:

  1. Surface gravity — applies a downward force to keep the player hugging slopes.
  2. Strafe — projects acceleration onto the character’s right vector, allowing side-to-side control during slides.
  3. Capsule shrink — reduces the capsule half-height from 88 to 44 units to simulate crouching.
  4. Exit condition — automatically exits slide when speed drops below Slide_MinSpeed or 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:

  • ServerCombat->EquipWeapon(OverlappingWeapon) directly
  • ClientServerEquipButtonPressed() RPC → server executes EquipWeapon

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_Yaw accumulates. Once it exceeds ±90°, TurnInPlace smoothly interpolates the yaw back to zero using FInterpTo, playing a turning animation via the ETurningInPlace enum.
  • When running or jumping, AO_Yaw is zeroed and bOrientRotationToMovement takes 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:

  1. ADemoCharacter::Elim() on the server — drops the equipped weapon.
  2. MulticastElim() — plays the elimination montage, disables input and collision on all clients.
  3. After ElimDelay (0.9 s), ElimTimerFinished() requests a respawn via DemoGameMode::RequestRespawn.
  4. The game mode picks a random APlayerStart in the world and calls RestartPlayerAtPlayerStart.

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.