Unreal 5 C++ Multiplayer – Blaster


This project is a fully networked multiplayer first-person shooter built in Unreal Engine 5 using C++ exclusively for all gameplay logic. It was developed as a deep dive into the fundamentals of UE5 multiplayer: authoritative game servers, state replication, client-side prediction, and the specific challenge of making hit detection feel responsive for players with real-world network latency. The result is a competitive shooter with seven weapon types, a complete match lifecycle, a lag compensation system, and a self-contained Steam session plugin — all implemented from scratch in C++ without relying on Blueprint for any gameplay behavior.


Multiplayer Sessions Plugin

A standalone GameInstanceSubsystem plugin that wraps Unreal’s Online Session Interface and exposes a clean delegate-based API to the lobby UI, completely decoupling session management from game logic.

  • UMultiplayerSessionsSubsystem handles create, find, join, destroy, and start — each operation registers a delegate handle on the OSS and clears it on completion to avoid dangling bindings
  • Custom multicast delegates (FMultiplayerOnCreateSessionComplete, FMultiplayerOnFindSessionsComplete, etc.) allow UMenu to bind callbacks without any knowledge of the OSS internals
  • Session creation detects an existing NAME_GameSession and automatically destroys it first, then re-creates with the cached parameters via bCreateSessionOnDestroy — preventing the common “session already exists” crash
  • Supports both Steam (OnlineSubsystemSteam) and the NULL subsystem for LAN testing without code changes
  • ALobbyGameMode::PostLogin reads DesiredNumPublicConnections and DesiredMatchType directly from the subsystem and triggers seamless ServerTravel when the lobby is full

Weapon System

AWeapon is the base class for all weapons. It handles state replication via EWeaponState, sphere-overlap pickup detection, ammo management with client-side prediction, and custom depth stencil for pickup highlighting. Three firing paths branch from this base.

  • AHitScanWeapon performs an instant line trace per shot, applies direct damage on the server, and dispatches a ServerScoreRequest on the client when SSR is active so the server can rewind and confirm the hit
  • AProjectileWeapon spawns actors with four distinct code paths depending on authority and SSR flag: server-host uses a replicated projectile with authoritative damage; a dedicated server spawning for a remote client uses a non-replicated SSR projectile; the owning client spawns a non-replicated prediction projectile and stores TraceStart and InitialVelocity for the rewind request; simulated proxies spawn a cosmetic-only non-replicated projectile
  • AShotgun fires NumberOfPellets traces simultaneously, accumulates per-character body and head hit counts into TMap<ABlasterCharacter*, uint32> structures, and then issues a single ApplyDamage call per target — avoiding the overhead of one damage event per pellet
  • ARocketProjectile uses a custom URocketMovementComponent that overrides HandleBlockingHit to return AdvanceNextSubstep and makes HandleImpact a no-op, allowing the rocket to fly through thin geometry; detonation is triggered exclusively by the UBoxComponent hit callback
  • AProjectileGrenade skips AProjectile::BeginPlay entirely and calls AActor::BeginPlay directly to avoid registering the OnHit callback — the grenade explodes via a destroy timer, not on first impact, and bounces are handled by OnProjectileBounce with an audio cue
  • Seven weapon types (EWeaponType) each carry their own crosshair textures, zoom FOV, fire delay, scatter parameters, and reload montage section
  • EFireType on AWeapon drives the dispatch in UCombatComponent::Fire() via a switch, keeping the combat logic type-safe without casting

Ammo — Client-Side Prediction

A Sequence counter on AWeapon tracks in-flight server requests. Every time the client fires SpendRound(), it decrements ammo immediately and increments Sequence. When ClientUpdateAmmo arrives with the server’s authoritative value, the client sets Ammo = ServerAmmo, decrements Sequence, and then subtracts Sequence again to account for shots still in flight. This prevents the ammo counter from rubber-banding without giving the client authority over the actual value.


Server-Side Rewind (Lag Compensation)

ULagCompensationComponent solves the fundamental problem of networked hit detection: the server sees enemies where they are now, but the client shot at where they were 50–200ms ago. Without correction, either clients miss shots that looked clean on their screen, or the server must trust clients — which enables cheating.

  • Every server tick, SaveFramePackage snapshots all 18 UBoxComponent hit-boxes on the character (head, pelvis, spine, arms, hands, backpack, thighs, calves, feet) into a FFramePackage timestamped with GetWorld()->GetTimeSeconds()
  • Packages are stored in a TDoubleLinkedList<FFramePackage> and pruned to MaxRecordTime (4 seconds) — older entries are removed from the tail each tick
  • When a client fires, it sends a Server RPC (ServerScoreRequest, ProjectileServerScoreRequest, or ShotgunServerScoreRequest) containing the hit target and the timestamp GetServerTime() - SingleTripTime, which is the server-clock moment the shot was taken
  • GetFrameToCheck walks the linked list to find the two frames bracketing the hit time and calls InterpBetweenFrames to reconstruct the exact box positions at that moment using FMath::VInterpTo and FMath::RInterpTo
  • ConfirmHit moves all boxes to the rewound positions, disables the skeletal mesh collision, performs a head trace first (early-out if headshot), then enables all boxes and traces again for body shots — finally restores everything and re-enables mesh collision
  • ProjectileConfirmHit uses UGameplayStatics::PredictProjectilePath instead of a line trace, replaying the projectile’s trajectory from the stored TraceStart and InitialVelocity with the rewound boxes in place
  • ShotgunConfirmHit processes all pellets in two passes: head pass first with only head boxes enabled, then body pass with heads disabled — accumulating counts into FShotgunServerSideRewindResult before applying a single combined damage call per character
  • The SSR flag on each weapon (bUseServerSideRewind) is toggled dynamically via HighPingDelegate on ABlasterPlayerController: when ping exceeds the threshold, SSR is disabled and the server applies damage directly, preventing high-latency players from exploiting the rewind window

Combat Component

UCombatComponent is the core of all combat behavior. It owns weapon equipping, firing, reloading, swapping, grenade throwing, crosshair spread, and FOV interpolation.

  • Fire validation uses WithValidation on ServerFire and ServerShotgunFire — the validate function checks FMath::IsNearlyEqual(EquippedWeapon->FireDelay, FireDelay, 0.001f) to detect fire-rate manipulation
  • The locally controlled client always runs LocalFire before sending the Server RPC, giving instant visual feedback. MulticastFire skips locally controlled non-authority clients to avoid double-playing the animation
  • Shotgun reloading works shell-by-shell: ShotgunShellReload is called from the animation notify, adds one round, checks IsFull() or CarriedAmmo == 0, and calls JumpToShotgunEnd to exit the loop early
  • CanFire has a special case for shotguns: it allows firing during ECS_Reloading if the weapon isn’t empty, enabling the classic “interrupt reload with fire” mechanic
  • Weapon swap preserves the secondary weapon in ABlasterHUD custom depth tan (stencil 252) so players can distinguish it from pickups (blue, stencil 251) at a glance

Buff System

UBuffComponent provides time-limited temporary stat boosts, all replicated via NetMulticast so every proxy sees the correct movement values.

  • Health and shield replenish use a per-tick ramp (HealingRate * DeltaTime) rather than instant jumps, giving a smooth HUD transition and making the buff feel impactful without a single-frame spike
  • Speed and jump buffs modify UCharacterMovementComponent directly and use FTimerHandle for automatic reset — SetInitialSpeeds and SetInitialJumpVelocity are called in PostInitializeComponents so the baseline is always captured before any buff fires
  • MulticastSpeedBuff and MulticastJumpBuff are Reliable because a missed movement value would cause visible desync on all proxies

Match Lifecycle

ABlasterGameMode drives three phases with a single countdown timer: WaitingToStart (warmup), InProgress, and Cooldown. bDelayedStart = true so the warmup countdown ticks before the match begins.

  • ABlasterPlayerController syncs the display clock to clients via ServerRequestServerTime / ClientReportServerTime — the client computes SingleTripTime = 0.5f * RTT and stores ClientServerDelta so GetServerTime() always returns a server-accurate value regardless of local clock drift
  • CheckTimeSync re-syncs every TimeSyncFrequency seconds (default 5) to correct accumulated drift over long sessions
  • Mid-game joins are handled by ServerCheckMatchState (server RPC) followed by ClientJoinMidgame (client RPC) which pushes the full timing state to the joining player before their first Tick
  • PollInit on ABlasterPlayerController caches HUD values (health, shield, ammo, score, grenades) behind boolean flags and flushes them to UCharacterOverlay once the widget is ready — this prevents values set during initialization from being lost if the overlay hasn’t been created yet

HUD

ABlasterHUD manages three overlay layers: UCharacterOverlay (in-game stats), UAnnouncement (warmup/cooldown), and a scrolling kill feed via UElimAnnouncement.

  • Each new kill-feed entry shifts existing messages upward by reading their UCanvasPanelSlot position and subtracting the slot height — no layout recalculation needed
  • The five-texture crosshair (center, left, right, top, bottom) is drawn each frame in DrawHUD with spread applied from FHUDPackage::CrosshairSpread, which is computed in UCombatComponent::SetHUDCrosshairs as the sum of velocity, in-air, aim, and shooting factors
  • High-ping detection runs every CheckPingFrequency seconds (default 20); Unreal compresses APlayerState::GetPing() to ping/4, so the comparison multiplies by 4 before checking against HighPingThreshold

Animation

UBlasterAnimInstance drives all character animation data every frame, kept entirely in C++ to keep the Blueprint graph clean.

  • Strafing yaw offset uses NormalizedDeltaRotator(MovementRotation, AimRotation) interpolated with RInterpTo to smooth direction changes
  • Lean is computed as Delta.Yaw / DeltaTime — the rotational velocity — clamped to ±90° and interpolated, giving a natural bank when turning
  • Left-hand FABRIK IK transforms the weapon’s LeftHandSocket from world space into hand_r bone space using TransformToBoneSpace, so the left hand grips the foregrip correctly regardless of weapon size or skeleton proportions
  • Right-hand rotation correction uses FindLookAtRotation from hand_r toward the inverse of the hit target direction, interpolated at 30 units/sec, ensuring the muzzle tracks the crosshair on the locally controlled client
  • bUseFABRIK, bUseAimOffsets, and bTransformRightHand are all suppressed independently during reload, grenade throw, weapon swap, and when bDisableGameplay is set — preventing IK from fighting the animation montage

GitHub Repository: nbertoa/ue5-cpp-multiplayer-shooter

Learning Source: Unreal Engine 5 C++ Multiplayer Shooter — Udemy

Demo: YouTube

Leave a comment

Create a website or blog at WordPress.com

Up ↑