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:
- 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.
- 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.
- 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.
- 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.
- 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
).
- 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.
- You can use the result of this client check to spawn predicted V/SFX.
- 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.
- 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.
- 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.
- Also note: The server-side history buffer must also be large enough to support this delay. See below.
- 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.
- 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! 
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:
- What static collider(if any) did my projectile collide with? This denotes the max ray distance (simplified; assumes no projectile material penetration).
- 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.