ShooterGame is arguably one of the most comprehensive samples Epic Games has given us for Unreal Engine 4. Studying it is something of a rite-of-passage for C++ developers, especially given the overall popularity of shooters and Unreals’ heritage. It’s incredibly useful for familiarising yourself with how Unreal expects you to build your game, particularly if that game happens to involve shooting at things.
I can tell you on good authority that the sample has served as the basis for a huge number of FPS games over the last few years, ranging from ARK to PUBG and even Hell Let Loose. The sample is very comprehensive, and I still return to it regularly for reminders and ideas. If you’re interested or involved in building multiplayer projects in UE4, it’s certainly something you should take the time to look at.
Although quite dated at this point, the sample is still full of useful code and utilities and Epic even add new items to it now and again to show off new engine features (such as Replication Graph).
Whether you choose to base your project on the sample, make the switch to Gameplay Abilities, or start entirely from scratch – ShooterGame is great for teaching you the basics of handling multiplayer gameplay. This article will look at how we can improve on one of these ideas in particular.
Note: This article assumes some degree of familiarity with UE4’s replication system and the ShooterGame project.
What’s a Burst Counter?
A Burst Counter isn’t really a known term (I’ve also seen the technique described as a Flash Counter) – but the concept is quite straightforward. In a Multiplayer FPS game – a player needs to know when other players are firing weapons. Typically this is done purely so that we can play effects, like muzzle flashes and audio. There are lots of ways you can do this, and ShooterGame showcases the following method:
UCLASS(Abstract, Blueprintable) class AShooterWeapon : public AActor { GENERATED_UCLASS_BODY() // etc... protected: UPROPERTY(Transient, ReplicatedUsing=OnRep_BurstCounter) int32 BurstCounter; UFUNCTION() void OnRep_BurstCounter() { if (BurstCounter > 0) { SimulateWeaponFire(); } else { StopSimulatingWeaponFire(); } } };
This modified snippet should give you a general idea of what’s going on – the Server increments the BurstCounter member each time the weapon fires a shot, and resets it to zero when the player stops firing. This replicated value is then received and used by non-local players to start and stop firing effects, such as muzzle flashes and audio. That’s really all there is to it (the player who is firing the weapon handles this locally).
This is much simpler (and cheaper!) than attempting to synchronize the entire weapon state across all connections. This is also a preferred approach to using Multicast RPCs, for several key reasons:
- This is a property that will be changing often. We want to get all the updates to this property that we can, but we also want to allow the engines’ network prioritisation to stagger these updates based on relevancy if it needs to.
- We want to leave the unreliable RPC buffer free for more important data, such as Character Movement and/or other mechanics that must remain responsive. (This matters more as we scale up).
- We still want to know if a player has fired, even if we miss a few updates. (Becomes more important as we flesh out the implementation below)
This initial implementation looks good then – but it only really works well with looping effects, and you’ll soon find out why. Property Replication in UE4 is reliable, but it is also lossy. What this really means is that you are not guaranteed to receive every change made to a variable; you are only guaranteed to receive the eventual state. This is a common tripping point, especially if you only test multiplayer in the editor and not on a real-world connection. (Don’t do this – package and test often).
At the top-level – the replication system in UE4 works by having the Server iterate over all replicated properties at the end of the frame, comparing them to last sent state, and sending the new values to each connection where neccesary. In the real-world, these changes take variable amounts of time to reach each client, and some of those changes may end up being skipped entirely (from the clients point of view at least).
In addition, because the counter is reset to zero when the player stops firing, a single shot may cause the server to not register any change at all (the counter increments then immediatelly resets). The result is that clients may never be notified about single-shots and short bursts.
The following is a method for solving this issue, and we can even save some bandwidth at the same time. What we really want to know when we get an update is whether the player fired the weapon at all, and ideally whether they are still firing or not. We also want to catch those pesky short-bursts and single shots. This approach is a much more resilient implementation, let’s start by replacing the int32 counter with a USTRUCT() like so:
USTRUCT() struct FRepBurstInfo { GENERATED_BODY() public: void ShotFired() { bIsFiring = true; ShotCounter++; } void StoppedFiring() { bIsFiring = false; } bool WasShotFired(const FRepBurstInfo& Other) const { return ShotCounter != Other.ShotCounter; } bool HasBurstStopped() const { return !bIsFiring; } FRepBurstInfo() : ShotCounter(0) , bIsFiring(false) {} protected: UPROPERTY() uint16 ShotCounter; UPROPERTY() bool bIsFiring; };
And update the weapon class accordingly…
UCLASS() class AWeapon : public AActor { GENERATED_BODY() public: void HandleFireShot() { // Firing implementation goes here. BurstCounter.ShotFired(); } void StopFire() { // Stop firing implementation goes here. BurstCounter.StoppedFiring(); } protected: UPROPERTY(Transient, ReplicatedUsing = "OnRep_BurstCounter") FRepBurstInfo BurstCounter; // Optional 'PreviousValue' parameter allows us to compare the new value to the old one when received. UFUNCTION() void OnRep_BurstCounter(const FRepBurstInfo& PreviousValue); }
We will implement the body of OnRep_BurstCounter()
below – but the first thing you may notice is that we have immediately saved nearly 2-bytes of data per counter, simply by reducing the counter itself to a uint16. Think about it – we only care about absolute values for a counter, and we certainly don’t need the full range that 32-bits gives us!
In a typical shooter, this counter will likely be changing and therefore replicating very often. Making small trimmings here can have a huge impact on the total bandwidth use of the game. While I’d advise caution against premature optimization, it doesn’t hurt to keep these things in mind as you go. Personally, I find the process of working out how to squeeze data into smaller and smaller packets is one of more enjoyable aspects of network programming.
We will be compressing this down even further (to a mere byte!) – but for now, let’s check we understand the new behavior:
- Each time we fire a shot, we set bIsFiring to true and increment the counter.
- Each time we stop firing, we set bIsFiring to false – but we *DON’T* reset the counter.
The client can now gather more information from each update, and from fewer bits! Better still, we are much more resilient to lossy changes and single shots. The counter will always replicate after a shot is fired even if we are no longer firing. It will eventually wrap of course, but that’s not a problem – we don’t care about the value, we only care about the changes.
We could start using this straight away, but there’s one thing we haven’t taken into consideration which is Network Relevancy (also known as “culling”). When an actor moves too far away from the player, it is “culled” by the networking system. This can be tuned per-actor/class, but is generally done because objects that are nowhere near the player typically don’t affect them. This is an essential concept for optimization and large-scale online games. When an actor is culled, it is fully destroyed by the client – as far as they are concerned it no longer exists at all.
When the actor comes back into networking range, the Server tells the client to spawn the actor, and updates it with the latest state of all replicated properties. Remember that we are no longer resetting the counter to zero when firing stops, and since the counter will likely differ from the construction-time value of zero, the RepNotify will be immediatelly called and trigger our effects.
We don’t want this to happen each time another player pops back into range, and thankfully we can solve this easily. A simple trick is to check the CreationTime
property of the actor when the RepNotify is called, and skip the rest of the method if it equals the current world time. The final implementation of OnRep_BurstCounter()
therefore looks like this:
void AWeapon::OnRep_BurstCounter(const FRepBurstInfo& PreviousValue) { const bool bFiredAnyShots = BurstCounter.WasShotFired(PreviousValue); if (bFiredAnyShots) { // Don't start new effects if this is an actor coming back to relevancy range. const bool bIsOpenChannelPacket = GetWorld()->TimeSince(CreationTime) == 0.f; if (bIsOpenChannelPacket == false) { OnBurstStarted(); } // We may have fired a burst then immediately stopped (e.g, shotgun, semi-auto etc.) if (BurstCounter.StoppedFiring(PreviousValue)) { OnBurstStopped(); } } // Burst count may have wrapped and be undetectable. // If we stopped firing but were firing previously, ensure the stop event still fires else if (BurstCounter.HasBurstStopped() && !PreviousValue.HasBurstStopped()) { OnBurstStopped(); } }
We’re almost there – but we can squeeze even more out of this. Now that we have these properties in a USTRUCT(), we can override the serialization behavior for when it is sent through the network. UE4 naturally provides a great system for this, and for more information you can check out Giuseppe Portelli’s fantastic article on the subject here.
By default, UE4 is able to replicated USTRUCT() members individually (so long as the struct is a member variable) – it will not replicate the entire struct if you only change a few of it’s properties. Note that if you decide to add your own NetSerialize() function, whatever you serialize will be sent each time as the engine assumes you know what you are doing.
Here is the final version of the Burst Counter struct, with an additional exposed option for byte/short compression, and a more optimal network footprint. Note that the option to compress to a byte cannot be changed at runtime, as this would break serialization between the Server and Client.
#define MAX_BURST_COUNT_BYTE 0x003f // 63 (6 bits) #define MAX_BURST_COUNT_SHORT 0x3fff // 16383 (14 bits) USTRUCT(BlueprintType, meta = (DisplayName = "Burst Info")) struct FRepBurstInfo { GENERATED_BODY() public: FORCEINLINE void ShotFired() { bIsFiring = true; ShotCounter = (ShotCounter + 1) % (bCompressByte ? MAX_BURST_COUNT_BYTE : MAX_BURST_COUNT_SHORT); } FORCEINLINE void StoppedFiring() { bIsFiring = false; } FORCEINLINE bool WasShotFired(const FRepBurstInfo& Other) const { return ShotCounter != Other.ShotCounter; } FORCEINLINE bool HasBurstStopped() const { return !bIsFiring; } bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess) { Ar.SerializeBits(&bCompressByte, 1); Ar.SerializeBits(&bIsIncrementing, 1); Ar.SerializeBits(&ShotCounter, bCompressByte ? 6 : 14); bOutSuccess = true; return true; } FRepBurstInfo() : ShotCounter(0) , bIsFiring(false) , bCompressByte(true) {} protected: UPROPERTY(Transient, BlueprintReadOnly, Category = "Burst Counter") uint16 ShotCounter; UPROPERTY(Transient, BlueprintReadOnly, Category = "Burst Counter") bool bIsFiring; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Burst Counter") bool bCompressByte; }; // This special template class is required to let UE4 know we have a NetSerialize() function. template<> struct TStructOpsTypeTraits<FHT_RepBurstInfo> : public TStructOpsTypeTraitsBase2<FHT_RepBurstInfo> { enum { WithNetSerializer = true, WithNetSharedSerialization = true, // We can use the Shared serialization method as we do not have any pointer members. }; };
What we’re doing here is packing the properties of our USTRUCT() into the equivalent of 8 or 16 bits, depending on the value of bCompressByte. Compressing this way gives us fewer available bits for the counter, but ask yourself – how likely is it that the player will fire 60+ shots between replication frames? (Spoiler: very unlikely). Even if they do, most of the time we aren’t interested in how many shots were fired, we just want to be made aware of changes.
Of course, if you *do* want to know how many shots were fired between updates, you can add a utility function to the struct like this. This is subject to wrapping issues of course, but only in very extreme cases.
uint16 FRepBurstInfo::NumShotsFired(const FRepBurstInfo& Previous) const { if (WasShotFired(Previous)) { // If > previous we haven't wrapped if (ShotCounter > Previous.ShotCounter) { return ShotCounter - Previous.ShotCounter; } // If < previous value, we just wrapped. 99% of the time this should be fine unless the fire rate is utterly stupendous else if (ShotCounter < Previous.ShotCounter) { const uint16 DiffFromMax = (bCompressByte ? MAX_BURST_COUNT_BYTE : MAX_BURST_COUNT_SHORT) - Previous.ShotCounter; return ShotCounter + DiffFromMax + 1; } } return 0; }
To be honest, the use of uint16 and the optional toggle may be superfluos here – you could avoid the optional bCompressByte and leave 7 bits for the counter. If it’s any help deciding, I am yet to require the full 16-bit counter on any project I’ve worked on to date (even with the crazy fire rates of weapons in Project Orion).
BONUS – AUTO-LOOPING
So we’ve managed to provide the client with more information in a smaller data packet – a great win! However one sneaky issue remains that becomes very noticeable with non-looping effects – and it’s something that only typically presents itself in a high-traffic game and/or on a poor connection…
VIDEO
Notice the stuttering effect of the players weapon? What’s happening here is that even though we are receiving all those nice low-cost updates to the burst counter, we are receiving them at variable time. Jitter and latency is not constant, and while using looping audio and FX is great for hiding the seams, they are typically more difficult to author and use.
A great way to fix this issue, or at least mask it, is to simulate a steady stream of packets by starting/stopping a looping timer when shots are fired. If we know the firing rate of the weapon and we know the player is firing, all we need to do is setup a looping timer to keep simulating the effects between updates.
This eliminates the stuttering issue, and while it may not be 100% accurate (e.g, players may appear to fire more shots than they really are in poor network conditions) – it’s much more preferable to the staccato-like effect of before:
VIDEO
Comments
No Comments