Gameplay Abilities System - Scoped Prediction Keys

Article written by Jon L.


Why are prediction keys scoped? Particularly for ability activation. Why can’t we just have a single prediction key for the ability’s lifetime?

Why do some AbilityTasks create prediction keys, and not others?


The core idea with prediction keys is that the client and server are both going to run some function, call it Foo(), that is instigated by something the client does, like pressing a button. The client tells the server about this via a Remote Procedure Call(RPC), and includes a FPredictionKey as a parameter. Both client and server run Foo() with that key set as the ScopedPredictionKey on the Abilities System Component and then any part of the system that wants to look at that key and associate side effects with it is free to do so. Keys intrinsically have a “Caught Up” delegate that fires client side once the key has been replicated back to the client via UAbilitySystemComponent::ReplicatedPredictionKeyMap.

The last sentence above is critical. Everything hinges on the idea that Foo() will produce side effects that will replicate back to the client as properties (such as applied gameplay effects and stateful gameplay cues). Knowing exactly when those server created, replicated side effects have been received is what we need to prevent A) double playing predictive effects and B) having gaps in predictive side effect removal and receiving the server replicated ones.

Gameplay Effect prediction works like this: if the client predicatively applies a Gameplay Effect, it binds a callback to remove the predictive Gameplay Effect via InPredictionKey.NewRejectOrCaughtUpDelegate in FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec. So when the prediction key “catches up” / “bounces off the server” we remove the predictive one and are left with the server authoritative replicated one. There is then logic in FActiveGameplayEffect::PostReplicatedAdd that detects when we have already predicted a replicated Gameplay Effect correctly and suppresses the replicated Gameplay Effect’s Gameplay Cue events for being invoked twice. What happens when we receive the replicated Gameplay Effect, is the predictive one is removed, we suppress Gameplay Cue events from the replicated Gameplay Effect, and the replicated Gameplay Effect remains.

Ability Activation is a bit special because there is an explicit response from the server - “yes this ability was activated like you thought” or “no it wasn’t” and that comes from a Server->Client RPC, not replicated properties. That is where the “Rejected” event comes from but it’s specific to ability activation and frankly doesn’t seem totally necessary in hindsight.

So, why not allow the prediction key to be valid through the entire ability’s lifetime instead of just the scope of “Foo”? Because in that model there is no way to synchronize these predicted events: you wouldn’t know as a client when you’ve received the side effects from an arbitrary point in the ability’s execution, or when you “should have” received the side effects but didn’t because you mis-predicted something. There is no replicated/synchronized time or frame count within the system. When you consider hitches, unstable frame rates, and fluctuating networking conditions, approximating these sync points doesn’t really work.

Adding a flag to every new task to generate a new scoped prediction key is probably not a good idea for the general case. But maybe in situations where it is a strictly linear graph and just say each node incremented the starting prediction key by 1 - maybe something like that could be worked out? Basically if you can deterministically evolve the key within the graph in such a way that at any point the client can be 100% sure “what the server has executed and replicated back to me” then maybe it could work. But I think this gets dicey when abilities are branchy or have server only parts.

If you really want a generic node that will generate and send prediction keys to the server, you could look at UAbilityTask_NetworkSyncPoint. However, that is causing additional RPC traffic and will introduce delays in your ability executions that are probably not desired.

Let me give you some more thoughts for alternative approaches. We did versions of these on Paragon but not in a generalized way that was usable in the engine:

Lag absorption could be used server side to "eat’’ some client latency and make something latent in the graph “feel” like it was predicted. Say you start a 500ms timer in the ability. The client would always wait 500ms but the server could subtract out the client’s ping from that timer (limited to some maximum). The non predicted Gameplay Effect would replicate back to the client at about the same point he would have otherwise predicted it. This has gameplay ramifications but can be an overall better experience in some cases.

For Gameplay Cues specifically - you can invoke them directly on clients without going through the prediction/replication system. You can just call UAbilitySystemGlobals::Get().GetGameplayCueManager()->HandleGameplayCue from anywhere. It kind of puts you at square one, but it does give you freedom to just ‘play the thing’ when you know it’s safe to do so. If you did this directly in the ability then it wouldn’t play on simulated proxies and you wouldn’t get the correction/undo functionality. However, for one-offs, this isn’t always a problem. For example, on Paragon we would play non replicated Gameplay Cues like this in BeginPlay for certain actor classes. BeginPlay is already replicated and everyone runs it at the same time, so we just talk directly to the Gameplay Cue Manager to invoke it.

Another example was that we had anim notify classes that invoked non replicated Gameplay Cues directly. The montage was already replicated and possibly predicted. Events within the montage don’t need an additional layer of prediction and reconciliation. If you were playing the montage, you know you should be invoking Gameplay Cue events at these given times.

I don’t recommend exposing a “Play Non replicated gameplay cue” to everyone everywhere. It will be misunderstood and misused. The goal with Gameplay Cues was that the designer invoking them wouldn’t have to worry about replication. So when we gave them access to non replicated Gameplay Cues it was always in that context - BeginPlay, Montages, etc. Only where we knew “everyone is already running this”.

  • This is based on a post by Dave Ratti
1 Like