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.
UMultiplayerSessionsSubsystemhandles 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.) allowUMenuto bind callbacks without any knowledge of the OSS internals - Session creation detects an existing
NAME_GameSessionand automatically destroys it first, then re-creates with the cached parameters viabCreateSessionOnDestroy— preventing the common “session already exists” crash - Supports both Steam (
OnlineSubsystemSteam) and the NULL subsystem for LAN testing without code changes ALobbyGameMode::PostLoginreadsDesiredNumPublicConnectionsandDesiredMatchTypedirectly from the subsystem and triggers seamlessServerTravelwhen 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.
AHitScanWeaponperforms an instant line trace per shot, applies direct damage on the server, and dispatches aServerScoreRequeston the client when SSR is active so the server can rewind and confirm the hitAProjectileWeaponspawns 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 storesTraceStartandInitialVelocityfor the rewind request; simulated proxies spawn a cosmetic-only non-replicated projectileAShotgunfiresNumberOfPelletstraces simultaneously, accumulates per-character body and head hit counts intoTMap<ABlasterCharacter*, uint32>structures, and then issues a singleApplyDamagecall per target — avoiding the overhead of one damage event per pelletARocketProjectileuses a customURocketMovementComponentthat overridesHandleBlockingHitto returnAdvanceNextSubstepand makesHandleImpacta no-op, allowing the rocket to fly through thin geometry; detonation is triggered exclusively by theUBoxComponenthit callbackAProjectileGrenadeskipsAProjectile::BeginPlayentirely and callsAActor::BeginPlaydirectly to avoid registering theOnHitcallback — the grenade explodes via a destroy timer, not on first impact, and bounces are handled byOnProjectileBouncewith an audio cue- Seven weapon types (
EWeaponType) each carry their own crosshair textures, zoom FOV, fire delay, scatter parameters, and reload montage section EFireTypeonAWeapondrives the dispatch inUCombatComponent::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,
SaveFramePackagesnapshots all 18UBoxComponenthit-boxes on the character (head, pelvis, spine, arms, hands, backpack, thighs, calves, feet) into aFFramePackagetimestamped withGetWorld()->GetTimeSeconds() - Packages are stored in a
TDoubleLinkedList<FFramePackage>and pruned toMaxRecordTime(4 seconds) — older entries are removed from the tail each tick - When a client fires, it sends a Server RPC (
ServerScoreRequest,ProjectileServerScoreRequest, orShotgunServerScoreRequest) containing the hit target and the timestampGetServerTime() - SingleTripTime, which is the server-clock moment the shot was taken GetFrameToCheckwalks the linked list to find the two frames bracketing the hit time and callsInterpBetweenFramesto reconstruct the exact box positions at that moment usingFMath::VInterpToandFMath::RInterpToConfirmHitmoves 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 collisionProjectileConfirmHitusesUGameplayStatics::PredictProjectilePathinstead of a line trace, replaying the projectile’s trajectory from the storedTraceStartandInitialVelocitywith the rewound boxes in placeShotgunConfirmHitprocesses all pellets in two passes: head pass first with only head boxes enabled, then body pass with heads disabled — accumulating counts intoFShotgunServerSideRewindResultbefore applying a single combined damage call per character- The SSR flag on each weapon (
bUseServerSideRewind) is toggled dynamically viaHighPingDelegateonABlasterPlayerController: 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
WithValidationonServerFireandServerShotgunFire— the validate function checksFMath::IsNearlyEqual(EquippedWeapon->FireDelay, FireDelay, 0.001f)to detect fire-rate manipulation - The locally controlled client always runs
LocalFirebefore sending the Server RPC, giving instant visual feedback.MulticastFireskips locally controlled non-authority clients to avoid double-playing the animation - Shotgun reloading works shell-by-shell:
ShotgunShellReloadis called from the animation notify, adds one round, checksIsFull()orCarriedAmmo == 0, and callsJumpToShotgunEndto exit the loop early CanFirehas a special case for shotguns: it allows firing duringECS_Reloadingif the weapon isn’t empty, enabling the classic “interrupt reload with fire” mechanic- Weapon swap preserves the secondary weapon in
ABlasterHUDcustom 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
UCharacterMovementComponentdirectly and useFTimerHandlefor automatic reset —SetInitialSpeedsandSetInitialJumpVelocityare called inPostInitializeComponentsso the baseline is always captured before any buff fires MulticastSpeedBuffandMulticastJumpBuffareReliablebecause 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.
ABlasterPlayerControllersyncs the display clock to clients viaServerRequestServerTime/ClientReportServerTime— the client computesSingleTripTime = 0.5f * RTTand storesClientServerDeltasoGetServerTime()always returns a server-accurate value regardless of local clock driftCheckTimeSyncre-syncs everyTimeSyncFrequencyseconds (default 5) to correct accumulated drift over long sessions- Mid-game joins are handled by
ServerCheckMatchState(server RPC) followed byClientJoinMidgame(client RPC) which pushes the full timing state to the joining player before their firstTick PollInitonABlasterPlayerControllercaches HUD values (health, shield, ammo, score, grenades) behind boolean flags and flushes them toUCharacterOverlayonce 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
UCanvasPanelSlotposition and subtracting the slot height — no layout recalculation needed - The five-texture crosshair (center, left, right, top, bottom) is drawn each frame in
DrawHUDwith spread applied fromFHUDPackage::CrosshairSpread, which is computed inUCombatComponent::SetHUDCrosshairsas the sum of velocity, in-air, aim, and shooting factors - High-ping detection runs every
CheckPingFrequencyseconds (default 20); Unreal compressesAPlayerState::GetPing()to ping/4, so the comparison multiplies by 4 before checking againstHighPingThreshold
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 withRInterpToto 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
LeftHandSocketfrom world space intohand_rbone space usingTransformToBoneSpace, so the left hand grips the foregrip correctly regardless of weapon size or skeleton proportions - Right-hand rotation correction uses
FindLookAtRotationfromhand_rtoward 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, andbTransformRightHandare all suppressed independently during reload, grenade throw, weapon swap, and whenbDisableGameplayis 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