Best practices for ClientSide prediction in a Gameplay Ability System

Hello everyone,

I’m currently working on a Gameplay Ability System (GAS) framework for Unity, and I’m in need of some advice regarding client-side prediction.

Here’s a quick overview of what I’ve been working on:
- GameplayAttributes: are numerical values representing an actor’s data, such as health, armor, speed…
- GameplayTags: are boolean values indicating an actor’s state, like whether they are dead or stunned…
- Actors: are ghost entities that holds Attributes and Tags, representing characters, projectiles, and more.
- GameplayEffects: These are the only way of modifying Attributes and Tags.
- Actions: Actions are Burst-compiled function pointers that define reusable logic, such as creating projectiles, applying gameplay effects, and more.
- GameplayEvents: These are points in the game’s simulation lifecycle, like “OnProjectileHit” or “OnCastStarted.” that can be used to trigger Actions.
- Abilities: are Scriptable Objects (SO) that define behaviors in a data-driven way. they contain events, actions, gameplay effects and conditions.

In my current implementation, the replicated data consists of:

  • GameplayAttributes: A DynamicBuffer of key-value pairs for Predicted and Interpolated Ghosts.
  • GameplayTags: A custom bitmask implementation for Predicted and Interpolated Ghosts.
  • GameplayEffects: A DynamicBuffer for Predicted Ghosts only.

Now, let’s dive into the prediction aspect:
In a client world, players only predict their actors’ data, like their controlled character or locally predicted projectiles. This is achieved by repeatedly executing actions that are allowed to be resimulated, such as applying a GameplayEffects or a Teleport and by skipping actions that aren’t allowed to be resimulated, like Spawning a Projectile or displaying Visual Effects.

Here are my questions:

  1. Predicting Actions on Interpolated Ghosts:
  • What would be the best approach to predict actions on interpolated ghosts? EG, a damage applied to an interpolated ghost, knowing that it will no be resimulation for a interpolated Ghost Actor.
  1. Replicating Single Frame Data:
  • What would the best way to replicate data that only exists for a single frame? EG, consider an instant GameplayEffect like ‘Damage’ that is produced and consumed within the same frame.

I appreciate any insights or suggestions you can provide.

Thank you for your help!

You basically need to run same simulation on the client and the host.
Then validate host and client, to hold same state. For example checking state hashes for given matching frame on client and the host.

And I suppose host need be able override client’s predictions and roll them back, if discrepancies occur.

1 Like

I’m interested in understanding how the Netcode package handles the rollback of interpolated ghost data before the resimulation phase.

According to the documentation:

  • Prediction only runs for entities which have the PredictedGhost. Unity adds this component to all predicted ghosts on the client, and to all ghosts on the server.
  • Netcode applies the latest snapshot it received from the server, to all predicted entities.

However, it’s unclear whether interpolated ghosts also have a rollback to the latest known tick before resimulation. Specifically, I’d like to know if this rollback process is exclusive to predicted ghosts.

In my perspective, it would be more logical for interpolated ghosts to be rolled back to the latest received snapshot before the local prediction phase ‘resimulation’. This approach ensures that the world is represented exactly as it exists on the server for the latest known server tick.

If this is the case, it simplifies the local prediction of Actions, whether they involve Predicted or interpolated ghosts, the logic remains consistent:

  1. ghosts are rolled back to the latest known server states.
  2. the client predicts the missing ticks until new tick data arrives from the server.

Thank you for your assistance!

Interpolated ghosts have no rollback and re-simulation/prediction; they simply receive state updates from the server, and interpolate state between these

The ability system, and all the game-specific logic around it, must be designed with the assumption that the concept of single-frame data (which indirectly also enables certain types of “event” mechanisms in ECS) doesn’t really exist on interpolated ghosts, because it would be too unreliable. The consequences of this, when it comes to implementation, all depends on what specifically needs to happen on interpolated ghosts when it comes to damage events. Here’s a few examples:

  • You want damage to affect a unit’s health on an interpolated ghost: Here you’d simply rely on the server running predicted DamageEvent code, updating the health from that, and syncing the health value to interpolated ghosts. Interpolated ghosts don’t really have to do anything here

  • You want enemies to have a visual color flash when they’re damaged: You’d have a system updating in the SImulationSystemGroup, checking for frame-to-frame changes in the health value of an interpolated ghost, and starting a color flash whenever a change is detected.

  • You want to display “damage number popups” when an enemy is damaged: here we can’t go with the previous approach of detecting health changes, because we want each individual damage source to display its own damage numbers. More than one damage event can happen in the same frame (standing in lava while being poisoned while getting shot at). What we can do is to have a Ghosted DynamicBuffer of damage events on interpolated ghosts:

  • The server runs all the predicted ability system code, and adds damage events to the ghosted dynamicBuffer of damage events

  • each damage event in there has a unique ID that gets incremented by the server when it creates and adds it to the buffer

  • damage events stay in the buffer for X amount of time. The server decrements a per-event timer and removes individual events from the buffer once their timer reaches 0. This timer should last just long enough to allow every client a reasonable amount of time to receive an updated buffer state, and do some processing on it. (Maybe like 300-500ms)

  • Clients iterate on those buffers, process only events whose ID sequence number is higher than the last one processed, and remembers the last ID value processed (then you’ll also possibly need to handle when the ID value loops back beyond the max int value). Important to note that clients can’t remove or modify elements from ghosted buffers, because those changes will just be overwritten next time state is received from the server

So the strategy is very dependent on exactly how much info you need in order to achieve what you want to achieve. The CPU performance and bandwidth usage of these approaches can vary a lot depending on the amount of info required. For example, if 1000 damage events happen, approach #2 has a bandwidth cost of syncing 1-1000 health floats, but approach #3 costs 1-1000 health floats + 1000 damage event structs in buffers and also adds the CPU cost of checking for unprocessed events in damage event buffers for a few frames. So it’s really worth having a solution that’s tailor-made for the use case

2 Likes

Cristal Clear!

Initially, my primary focus was on enhancing the local player experience. However, it now appears to be pointless to predict data related to interpolated ghosts.
Options 1 and 2 are pretty obvious and I can’t think of a smarter approach.
As for option 3, it could be very demanding in term of memory, CPU, and bandwidth. I’ve considered this before, but the current delta compression method used in the Netcode package for DynamicBuffers can be extreamly resource-intensive. especially given that the buffer size will frequently change, resulting in the need to send the entire buffer over and over.
However, this idea can be optimized by:

  1. Reversing it and adding the buffer to the damage source rather than the receiver, as it exclusively pertains to that specific ghost/connection.
  2. Since these types of events are not critical to the simulation, including the tick at which they were added instead of the eventID. Since the server already possesses information about ticks received by clients, this approach enables the server to clean up non-essential events at the appropriate tick.
  3. Reuse events within the buffer by resetting each element to its default value when it is no longer needed, instead of continually adding/removing them. This approach avoids the need to transmit the entire buffer with each change.

Thanks again @philsa-unity

1 Like

I have implemented a system for displaying damage/impact numbers on screen to the player. I used your third option in my project for displaying them, to reduce bandwidth usage as you stated. Some details from my side:

I am using two separate dynamic buffers for the events with a prediefined length which are prefilled with empty elements and directly attached to the unit entites which are affected by them. One dynamic buffer is used by the server for reporting the real numbers to the client while the other is used only by the client only for damage numbers. Whenever an impact happens server and client (in case of prediction) put an event in their corresponding buffer. As philsa stated the event has to remain some time in the buffer, so I used an expiration server tick index on each event. Whenever the expiration tick is reached on the server the event element is nullified (not really removed from the buffer) and their effect is applied to the unit properties (health in case of damage). The client also removes predicted events after expiration to ensure it gets in sync with the server data again in case the server does not confirm it. The health and other unit properties are tracked by an ECS system and there the properties are calculated from the dynamic buffer to persist the current health and other properties so they are always up-to-date without looking into the event buffers. These up-to-date data is not transferred between server and client, so each side has to maintain and calculate the values themself.

Thats the more easy part. Now something tricky is required. The client can predict damage and the server states the same or another number for the same event a bit later, but the client should show the self-predicted damage/impact immediatly until it receives the corresponding server event. Then it nullifies his own predicted event in favor of the server event. The ECS system then applies the server event data and in case of divergencies in the prediction the unit properties are corrected. Because the client already showed the predicted flying damage numbers of the arrived server event it is also flagged with a suppression, so the client does not show any damage numbers for that event on the screen again.

Each event has a event kind so this is one criteria for matching predicted client and server events. Other match criteria depending on the event kind. For predicted shots for example the client side shot ID can be stated in the event which is also known by the server. For other cases with no unique ID I rely on the server tick index. But because the client only guesses the server tick index and never exactly knows it, the creation tick of the client-side event has not to match with the server-side event. So a server tick index range has to match then. This is not precise, but I had no other idea here and in it works for my project because these events are only applied in intervals and not directly consecutively.

There are still some more smaller details. Overall I find the code to get this work in my project quite complicate, I wish it were easier. But certainly there are some other solutions possible.

1 Like

This is quite impressive!
Why did you opt for a server expiration tick approach instead of directly verifying whether the client received the message sent by the server?

By the way, I’ve discovered that Fortnite uses RPCs for handling similar events, which indeed offers advantages in terms of flexibility and bandwidth utilization. However, I recall that in earlier versions of the Netcode package, there were issues related to sending RPCs too frequently.

@NikiWalker is this limitation still exists in the 1.1 version ?

1 Like

First to summarize the expiration and verification aspects of the approach in my project. There are two purposes of the event buffer attached to the units: The first is calculating up-to-date health and other properties for a unit. This includes the client-side prediction of these unit properties for example when using predicted shooting. The other purpose is show the event effect on screen to the player so also being an event list for each unit.

The expiration tick index on each event is important to free up the limited event slots per unit and to remove the event from event list again. When an event reaches its expiration on the server that change of the property are also incorporated directly into the unit property field maintained in a separate ECS component. Because the dynamic buffer for the events and the unit properties component are part of the same entity they should both be consistent when the client gets new data from the server as far as I understood Netcode for Entities. Note: I think this can also be done in another way of simply directly incorporating the changes into the unit properties without delaying it, but I cannot recall the reasons exactly anymore why I implemented it this way. Probably to keep handling of server and client the same, because prediction always needs some kind of calculation on top of the current unit properties.

For the client the expiration tick index it is also important to drop unverified unit property changes, so the unit properties are corrected on the client-side in such cases when the expiration happens. So this is one part of the verification processes.

The other important part of the verification process is the matching of the predicted client-side events with the new incoming server-side events. Whenever the client detects a server-side event which is covered by predicted client-side event, it drops his own predicted event. So the client-side expiration and the matching of the events are both the verification part.

Now to answer you question: In my project it is only possible to validate certain event kinds in the event list via entity components. For example each shot has an entity with a component stating the result of the shot so a client-side validation would be possible. But there are other event kinds like minor regains of unit properties over time which neither have a dedicated entity nor a component, so they are only stated as an event in the event list. No other verification can be done here than using the ones I mentioned above. Still it would be possible to create entities and components for these minor events, but I personally found it not reasonable, because it would make handling for that events more complex. So simply all verifications are based on the event lists and and do not consider other entities to keep this a general logic.

2 Likes

This issue is still present yes. UTP will log errors if you exceed the supported RPC queue.

I’d be very interested in how one goes about implementing this! Is there any example code or documentation on creating a network-serialized DynamicBuffer?

You can read about it here: https://docs.unity3d.com/Packages/com.unity.netcode@1.1/manual/ghost-snapshots.html#authoring-dynamic-buffer-serialization

it’s very similar to synchronizing regular components