April 20, 2021
Implementing Advanced Latency compensation in Battlefield 2
Latency compensation
Network latency is an unavoidable problem in multiplayer games. In old shooter games, shooting without taking into account your latency may be unreliable, as the command to shoot may reach the server after the target has already moved. Most video games solve this with a latency compensation system: when testing for hits, the server takes targets back in the time to where the client saw them. This does come with its own issues, such as being unable to reactively dodge shots from high ping players as any of your recent actions will be rewinded. However, most games have decided that the philosophy of favoring the shooter is the best option.
Battlefield 2 (2004) does implement this type of latency compensation, but there’s another issue that stems from latency that this type of compensation doesn’t solve that we will discuss today, but first we need to understand why latency compensation doesn’t need to rewind the shooting player itself.
Local Prediction
When a player clicks a button, he expects to see the result right away. Waiting for a reply from the server before seeing the effect of a button press would feel terrible, especially with higher ping. Video games solve this with prediction. It’s preferable to assume that the result of the input will be the same on client and server, and that its okay to predict the result locally without waiting for the server to report it. If the result is mispredicted, we can just fix it and move the player to the state the server decided is correct.
Why do we not need to rewind the shooting player?
The shooting player uses local prediction on all of his actions. If he moves before shooting, he will be aiming from his already new position. On the server side the server receives his commands in the same order, and the shoot command is received after the movement command. Assuming mispredictions are rare, the player will be shooting from the same position on both the client and the server. If, for example, the shooting player would have predicted only his rotation and not his position, then rewinding him would have been correct.
Where does this fail?
What if the player is not the one controlling his movement? This is a rare edge case many video games don’t bother taking into account. However in Battlefield 2’s Project Reality mod, it is a very common occurrence. In Project Reality, most vehicles have two different players as crew members. One controls the driving and the other the gun. The gunner predicts the turret’s rotation (relative to the base of the vehicle) which depends on his own input alone, but he cannot predict the vehicle’s base rotation, as they are controlled by a remote player’s inputs. This causes any rotations the base of the vehicle, which also change the turret’s final rotation, to change on the server before the gunner is aware of that, and may cause him to shoot at a direction he did not intend.
Example
- The vehicle is stationary. The gunner is aiming exactly north.
- The driver starts rotating the vehicle.
- The gunner clicks shoot, still seeing that he’s aiming north.
- The server receives the command from the driver, starts rotating the vehicle. The gun is no longer pointing north.
- The server receives the fire command from the gunner, shooting a projectile in a completely different direction
- Shot lands far away from where the gunner was aiming.
Naive solution
What if we rewind the shooting player’s position/rotation in these cases?
This is wrong, the shooting player does locally predict his turret’s rotation, and if he tries to shoot at a target after rotating his aim towards it, the server will be rewinding his recent input before firing. We want the gunner to hit exactly where he aimed on his screen when he clicked fire.
Real solution
Instead of rewinding the player, we measure the rotation that the base of the vehicle did during his latency, and fix the projectile’s direction using that. This means only the unpredicted movement will be rewinded.
In other words, we bring the base of the vehicle back in time, but keep the turret’s rotation relative to the base in the present.
We first calculate the projectile’s direction in the vehicle’s frame of reference. As the player is fully predicting the turret’s rotation, this is the direction relative to the base that the player chose to fire at. Then we use the vehicle’s old rotation (which depends on the player’s latency) to rotate to projectile back to where the player was aiming. If the vehicle was moving, we also need to adjust the projectile’s start position by the difference in position.
The shot in these two videos is the same one. The driver and gunner both have high latencies to the server. The projectile is set to be networked (both sides receive the actual results before displaying them)
Implementation
- First I find the game’s
FireComponent::createProjectile
function on the server using IDA.- The linux server binaries were compiled with ELF symbols, so it is easy to find it there.
- Windows is more tricky, since we do not have symbols for it. One of the ways is to rely on is the COM-style interfaces the developers of BF2 have created. These assign each class a list of interface IDs, and each class implements a queryInterface function in its virtual table to select a specific interface. The interface IDs are an int32 and their values are the same in windows and linux. Searching for them leads us to the FireComponent class. From there we can find the createProjectile function in its virtual table, at around the same offset it is in the table on linux.
- I hook the function using our pybind dynamic library that we force the game to load through Python, which allows us to set a Python callback for it. The hook allows us to change the starting position and velocity of the projectile before it is created.
- After checking that this player currently needs this kind of latency compensation, we read from memory the player’s current view latency. This value is reported by the player and includes the total sum of his latency, including ping, interpolation delay and actionbuffer queue delays.
- I then read from game memory the current and old (depending on latency) position (vec3) and rotation (quaternion) of the vehicle (from the same Latency compensation system that is already implemented in Battlefield 2, which is storing object histories in a cyclic buffer)
- I then take the speed vector, undo the vehicle’s current rotation, and apply the old rotation.
speed = rotateVec3FromQuatInverted(nowquat, speed) speed = rotateVec3FromQuat(oldquat, speed)
(And also apply the difference in position, which should be obvious enough).
The direction and start position of the projectile are now exactly what the client saw when he clicked fire, making the game feel more responsive and correct, even on very high latencies.