An example of how to implement client-side prediction for projectiles in Unreal Engine, optionally using the Gameplay Ability System.
https://dev.epicgames.com/community/learning/tutorials/LZ66/projectile-prediction-in-unreal-engine
Quite a bit different from my approach. I call it Client-Fakey, Server Auth.
System uses object pooled projectiles and does not replicate them. Only replicate simple data.
Client fires its own on input, rpc’s server its projectile velocity vector and a few others for anti-cheat.
Server calcs its projectile velocity and does some comparisons against clients data.
Fires shot, then multicasts to Sims only the spawn point and velocity.
Sims spawn the projectile at the servers muzzle location and apply the servers velocity.
Each proxy is doing its own simulation of the firing process.
I also have a dedicated projectile class for each proxy.
Client projectile applies local hit fx.
Servers handles damage
Sims can apply hit fx (squibs), but they also have an audio component to handle the “whizz” you hear from close flybys.
Server MC’s the hit data (impact/normal) to Sims so they can apply server authed decals etc.
Very low data footprint.
Also need to note the Character movement component (CMC) doesn’t sync movement. It doesn’t forward project sims to catch up to the server or clients position. Nothing in the system tries to sync. It’s impossible due to pings, ping variance (jitter) and packet loss.
Client-side prediction doesn’t really “Predict” anything. Its only allowing clients (autonomous proxies) to apply movement on input and simulate it locally. The server takes the same moves and compares against clients results for each move and RPC’s either an ack or a correction.
You can easily confirm this in PIE (client mode) with 2 players. Turn on emulation for clients with a 200/200 ping, 1% loss. Player movement will lag behind on each other screens. Jumps are the easiest to see.
Since movement isn’t forwarded you don’t want anything else to be forwarded. If you forward project your projectiles they will not hit exactly what they where meant to hit in regards to players.
Autonomous are always ahead of the server. Servers are ahead of Sims.
Hey awesome tutorial! Well written and very pedagogical.
I think you forgot this:
template<>
struct TStructOpsTypeTraits : public TStructOpsTypeTraitsBase2
{
enum
{
WithNetSerializer = true,
WithIdenticalViaEquality = true,
};
};
One caveat with the AbilityTask: If it’s used in a very short GameplayAbility (like fire → EndAbility), the server does not have time to “Reject” or “Accept” the ability (tested with CanActivateAbility = false on the server with average ping on client and server) because the Task is already destroyed on the client I believe. This is a limitation coming from GAS itself it seems. (It’s possible to add a simple WaitNetSync(ClientWait) or a delay before ending the ability to fix it!)
That’s a great point! In practice, I’ve never encountered this, since I’ve never had a gameplay ability shorter than 200ms (since mine are usually tied to some kind of animation), but it is possible. And you’re right: placing an Only Client Waitnet sync right before your End Ability Locally accounts for this. In hindsight, it’s probably safer to do bookkeeping stuff like this in your ASC or player controller to account for these cases.
Yes this was only during projectile testing, I don’t think it will happen often in real game abilities.
Another issue I encountered is regarding the targetData replication to the server. In my case the targetData seems to get invalidated (vectors have very large values). What is strange is it kept happening in package builds but not in PIE.
I had to copy the fields before consuming… I see in Lyra they even use MoveTemp for the const_cast… (eg. LyraGameplayAbility_RangedWeapon)
void UAbilityTask_SpawnProjectile::OnSpawnDataReplicated(const FGameplayAbilityTargetDataHandle& Data, FGameplayTag Activation)
{
// Make a local copy so Consume can’t invalidate what we read
FGameplayAbilityTargetDataHandle LocalData(Data);
const FGameplayAbilityTargetData* Base = LocalData.Get(0);
if (!Base || Base->GetScriptStruct() != FGameplayAbilityTargetData_ProjectileSpawnInfo::StaticStruct())
{
if (ShouldBroadcastAbilityTaskDelegates())
{
FailedToSpawn.Broadcast(nullptr);
}
EndTask();
return;
}
const FGameplayAbilityTargetData_ProjectileSpawnInfo* SpawnInfo = static_cast<const FGameplayAbilityTargetData_ProjectileSpawnInfo*>(Base);
// Copy fields before consuming
const FVector _SpawnLocation = SpawnInfo->SpawnLocation;
const FRotator _SpawnRotation = SpawnInfo->SpawnRotation;
const uint32 _ProjectileId = SpawnInfo->ProjectileId;
AFA_PlayerController* PC = Ability->GetCurrentActorInfo()->PlayerController.IsValid() ? Cast<AFA_PlayerController>(Ability->GetCurrentActorInfo()->PlayerController.Get()) : nullptr;
if (!PC)
{
if (ShouldBroadcastAbilityTaskDelegates())
{
FailedToSpawn.Broadcast(nullptr);
}
EndTask();
return;
}
// Consume the client's data. Ensures each server task only spawns one projectile for each client task.
if (!Cast<UFA_AbilitySystemComponent>(AbilitySystemComponent)->TryConsumeClientReplicatedTargetData(GetAbilitySpecHandle(), GetActivationPredictionKey()))
{
UE_LOG(LogFA_PredictedProjectile, Warning, TEXT("SpawnPredictedProjectile task in ability (%s) received spawn data but failed to consume it. Ignoring duplicate data..."), *GetNameSafe(Ability));
return;
}
const float ForwardPredictionTime = PC->GetForwardPredictionTime();
[...]
}
Now I correctly spawn projectiles but I get a weird bug for few seconds on the client, the explosions don’t appear, and only after a few seconds every explosions appear at once. After this no more issue. Maybe some asset loading…
Also I get a lot of `Warning: Missed prediction: Fake projectile (BP_Projectile_C_2147481953) missed its detonation. Reconciling by destroying the fake projectile and using real projectile’s detonation…`. Need to investigate… It seems alright visually.