Implementing Lag Compensation in Unity ECS for Accurate Hit Detection

We have developed a multiplayer game using Unity ECS and NetCode.

We are using unity 2022.3.21f1 and also as a base of the project we are using the OnlineFPS sample project with version 1.1.0-exp.10. So, the weapon system and character controller are set like the sample project.

Recently, I checked the updated sample with Unity 6. So, not everything but I have updated the system related to the weapons as they are more efficient and easy to manage.

We are facing an issue with hit detection accuracy due to high latency (ping). Here’s the scenario:

  1. Client-Side Perception:
  • The client sees an enemy in their crosshair and registers a hit.
  • The client sends the shot details to the server.
  1. Server-Side Reality:
  • Due to network delay, the enemy’s position on the server has changed when the server processes the shot.
  • As a result, the server does not validate the hit, even though the client perceived it as a successful shot.
  1. User Impact:
  • The player perceives this as a bug because their hit is not applied, despite appearing valid on their screen.
  • This discrepancy creates frustration and affects gameplay fairness.

What We’ve Tried:

  • Server-Side Validation:
    • The server is authoritative and validates hits based on its current game state. However, this leads to the issue described above.
  • Unity ECS NetCode:
    • We are aware that there is inbuilt lag compensation in Unity ECS.
      But can we do something using like PhysicsWorldHistory, which allows rewinding physics states for hit validation?
    • We need guidance on integrating these into our current system.

What We Need Help With:

  1. Lag Compensation Implementation:
  • How can we effectively use PhysicsWorldHistory to rewind entity positions on the server and validate hits based on the client’s perspective?
  • Are there any best practices or optimizations to handle this in a high-ping environment?
  1. Integration with Current System:
  • In our current implementation:
    • The server uses RaycastInput to validate hits.
    • Damage is applied only when the server registers a hit.
  • How can we integrate lag compensation without introducing significant server-side performance overhead?
  1. Other Recommendations:
  • Are there alternative approaches for handling this issue if Unity ECS lag compensation tools are not sufficient for our use case?
  • I have seen many improvements in the latest version of Netcode For Entity and Unity 6, and migrating to Unity 6 will help or improve the hit detection accuracy. In case there is any improvement in the built-in lag compensation systems

Additional Context:

  • We are using Unity ECS and NetCode with authoritative server logic.
  • We aim to support competitive gameplay, where accurate hit registration is critical, even under high-latency conditions.
  • We are using the OnlineFPS sample project as a base. So, the weapon system and character controller are set like the sample project.

Request: Any guidance, code examples, or suggestions on implementing lag compensation in Unity ECS would be highly appreciated. We’re looking to ensure fair and accurate gameplay across different network conditions.

I would suggest you look at these:

Both uses Client Prediction and Lag Compensation.

2 Likes

Basically you’d have to borrow the idea from “rollback netcode” to alleviate the issue – afaik if you have peers in a same battle having significantly different network quality and framerate the issue is unsolvable, i.e. at least one peer will see inconsistent graphics in the worst case.

The “rollback” idea is simple, when you have an authoritative server

  • all peers and the server hold a recent history of “rendered game states” as well as recent history of “inputs from all peers” while
  • the trick to determinism lies in the fact that all parties know “who did what at which frame id”, and
  • the trick to prediction error correction lies in the use of “peer-input-confirmed-by-server mark (or host if you don’t have server)”

, the rest is just implementation details.

However the difficult part only begins when you apply the basic idea of “rollback”, in your case let’s say player#A perceives that it should’ve made a hit to player#B, yet due to large network delay neither the server nor player#B recognizes this and eventually the server judges that the hit was invalid and broadcasted a frame of game state for overriding player#A’s graphics – one approach to alleviate this issue in terms of graphical consistency is to let player#B’s framerate freeze/decelerate for several milliseconds to wait for player#A’s input to reach it (not necessarily server-confirmed input, could be just relayed UDP packet directly from player#A, which is still better than purely local prediction) – yet the condition to trigger/break such freezing/deceleration can be tricky to implement and prone to a seemingly deadlock if not treated carefully enough, moreover this approach doesn’t fit well if you have 10+ ppl in the same battle.

In an extreme case where player#A’s network is too bad, the server can broadcast a “latest ground truth game state/frame” to all peers for helping player#A pump up its local game state to a synced and up-to-date position (thus player#A’s graphics would be inconsistent) as well as helping other players break the freezing condition – however, whether or not you want to ignore player#A’s unconfirmed inputs or use “server-predicted player#A inputs” when composing this “latest ground truth game state/frame” on server side is application specific.

My work-in-progress project is using this approach and opensourced so you can see if the performance of it fits your need: Sharing an opensource delay-based rollback netcode project with some success in internet tests.

Cristian and I deep-dived on this recently, so I’ll provide a rough version of a doc I mean to update in our manual. To give more details to the 03_HitScanWeapon sample linked above, I’ll recap the problem. Note the three different timelines:

  1. The Shooter client (who is running ahead of the server, on the client predicted timeline) is shooting at a Victim who is on the Shooters own Interpolated timeline. This means that, even ignoring network latency, the shooter is shooting at a previous version (position etc) of the Victim ghost entity. Read more about Interpolation Buffer Windows here.
  2. Extra nuance: The Shooter human player is seeing (with their eyeballs) a version of the Victim that is even more render frames behind, as there is render-induced latency (software + monitor) as well as a tiny amount of hardware latency, as well as a variable amount of simulation tick latency. You can try to account for it by adding additional ticks of delay to the PhysicsWorldHistory API calls. In our sample, we use a constant.
  3. Extra nuance: The shooter might be simulating a partial tick, which means IsFirstTimeFullyPredictingTick is false. Thus, in our default implementation, the hit detection must wait for the first full tick, which may be a few more render frames. You can raise hit detection checks on partial ticks, but it’s an incremental improvement that we can get to later.
  4. So, the Shooter is simulating a full tick, and the mouse input is detected, and the shot raycast is fired on the client. You can see we check for this shot input here.
  5. We now fetch the historic collision world for the tick the shot was fired on, which is either zero, or some known value (if you’re using the additionalRenderDelay).
  6. Nuance: You can also see that we Assert that the result returned by the GetCollisionWorldFromTick call didn’t clamp, to ensure we’re actually storing the history correctly on the ClientWorld. Increase your LagCompensationConfig.ClientHistorySize via the NetCodePhysicsConfig sub-scene authoring if it’s too small for this.
  7. You can use the result of this client check to spawn predicted V/SFX.
  8. The Shooters input is automatically sent to the server via the netcode package, and the server already tracks the clients ping value, but we need to also opt-into sending the clients “Interpolation Delay” as well. This delay is the difference between the predicted tick (i.e. predicted timeline) and their interpolation tick (i.e. interpolation timeline), as this differs for each client. This combination of ping + interpolation delay is how the server knows which historic CollisionWorld it should use for the Victim, when performing hit validation. You can opt-into this data by enabling TrackInterpolationDelay on the OwnerPredicted player ghost authoring. This adds the CommandDataInterpolationDelay component to the ghost, which netcode writes to.
  9. On the server (which is on the server-authoritative timeline, which naturally sits between the client predicted and interpolated timelines), the server will (hopefully, network errors notwithstanding) receive the input from the Shooter on a tick just before it is needed (thanks to TargetCommandSlack), and it can then call the same GetCollisionWorldFromTick API (using this same code), but it will pass in the additional delay to match the collision world to the version of the Victim that most closely resembles what the Shooter saw. I’d also recommend adding an Assert to ensure this interpolationDelay.ValueRO.Delay value is never zero, on the server, as it cannot feasibly be if working correctly.
  10. Note: The victim entity may not have been standing exactly there, for many reasons:
    • The server may be wrong about the Shooter clients exact ping (especially bad in the presence of jitter i.e. continuous ping variance).
    • The Shooter client may not have received up to date information for the Victims ghost (via a snapshot) due to packet loss or congestion (this is why the Interpolation Buffer window is so important). Therefore the server would be reconstructing a position that the client didn’t even use. Note SmoothingAction settings on the victims LocalTransform. Extrapolation may help or hurt your use-case. Also look into snapshot throughput, and whether or not you are reliably receiving victim snapshot data in time, in realistic, real-world conditions.
    • As previously mentioned, the Shooter may have been aiming at a partial tick interpolated position of the Victim. Typically this doesn’t matter, as anything you shoot isn’t typically moving fast enough to miss if off by a fraction of one frame.
    • The shooter may have mis-predicted their own position (e.g. by sitting in a fast moving vehicle), and therefore the shot ray.origin differences may have led to the client and server disagreeing about the hit.
  11. Also note: The server-side history buffer must also be large enough to support this delay. See below.
  12. The server resolves the shot as either a hit or a miss, and (presumably) updates your GhostField HP values, and may even destroy the entity etc.
  13. As expected, said changes get forwarded by netcode back to the Shooter (and Victim) clients, which can update their own prediction, and play the “server authoritative” SFX like hit markers/death screens etc.

This is the broad operation. Today, we only support storing (and thus rolling back to) historic DOTS Physics CollisionWorld’s, but we would like to support storing & rolling back GhostField state too (I can’t make a promise here, nor an ETA).

You can also see from this that the Victims network latency (& network quality) has no impact on the quality of the Shooter’s shot lag compensation. Why? Because the server is the authority on where the Victim entity is & was, and it can pass this info to the Shooter without the need for the Victim to even be connected. The Victims network does come into play for more nuanced scenarios (like peekers advantage), but that’s another post! :grin:

A change is coming to 1.5.0 (most likely) to increase the PhysicsWorldHistory buffer max size from 16 to 32. This thread also has some other broad lag compensation advice.

Other than that, it is typical to limit how far the server is willing to rollback, to get a nice balance between ‘advantaging the shooter’ (by performing lag compensation at all) and ‘advantaging the victim’ (by reducing the window (& thus frequency) of being shot after entering hard cover).

Possible CPU optimization: If you can maintain the additional complexity this introduces, you can opt out of deep copying static colliders (via the LagCompensationConfig.DeepCopyStaticColliders toggle), which can reduce the job cost, particularly for larger maps. But you’d then need to separate your lag compensation shot checks into:

  1. What static collider(if any) did my projectile collide with? This denotes the max ray distance (simplified; assumes no projectile material penetration).
  2. Once we have that, perform the lag compensation against dynamic colliders only (via fetching historic worlds).
    This solution also requires that you don’t ever move static geometry during gameplay (or only do so when shots are not being fired). It complicates an already complicated interaction.

Also note: We’ve investigated - and found some issues with - Input/Commands being lost. This can adversely affect Lag Compensation quality. Fixes are being worked on (again - this isn’t a commitment, nor ETA).

I’d also recommend deep diving into the FirstPersonCharacterController in that sample, especially relating to physics updates, as they can both affect lag compensation quality. See changelog entry below for a recent improvement there.

This system should be burst compatible, as it should be for the client. It needs to be performant enough to run within the client prediction loop, so the server should have no problems calling this once per tick per shot. But server authoritative netcode does have serverside simulation cost, and this one is probably one of the most critical in a competitive FPS.

Sure:

  • We encourage users to making local package modifications to try to improve Netcode for Entities. We’d be more than happy to integrate specific fixes and patches if we can validate them.
  • It’s common to add some “fuzziness” to the server-side check (like making the colliders slightly larger or the raycast thicker) as a broad solution for minor discrepancies. A similar version of this would be to pass the partial tick fraction as part of the shot input, and then reconstruct that sub-tick victim collider position on the server using an interpolation of the two physics worlds, but we don’t support that out of the box (yet?).
  • Similarly, allowing a tolerance distance for Shooter ray.origin position - to account for client prediction indeterminism - allows you to trade a little bit of client authority for more consistency.
  • You can go further with client authority in some games/use-cases. Very game dependent though, obviously.

The only thing that comes to mind is the 1.3.2 changelog entries:

  • PhysicsWorldHistory now clones collision worlds after the BuildPhysicsWorld step for the given ServerTick. This fixes an issue where the CollisionWorld returned by GetCollisionWorldFromTick is off-by-one on the server. It is now easier to reason about, as the data stored for ServerTick T now actually corresponds to the BuildPhysicsWorld operation that occurred on tick T (rather than T-1, which was the previous behaviour). We strongly recommend having automated testing for lag compensation accuracy, as this may introduce a regression, and is therefore a minor breaking change.
  • PhysicsWorldHistory now deep copies dynamic colliders by default (see fix entry). Performance impact should be negligible.

Final thought: The team would also very much appreciate real-world data from all folks building FPS games and battle testing our lag compensation solution. Our sample/template projects aren’t deep enough to provide as much as we’d like, and we haven’t always got the resources to investigate/iterate ourselves. I have recently improved our integration testing in this area, but there is always more to be done.

1 Like