I'm currently designing and implementing a multiplayer game server for a fairly basic 2D tile game (somewhat similar to old Zelda games like LttP). The PvP aspect of this game involves fast paced combat where players will be in close contact/interaction with each other using swords to deal damage.
As such I want the system to be as smooth and as accurate as possible given a range of connection speeds and pings. I've run into one problem using Prediction and Reconciliation on the client-side.
My current implementation works beautifully for movement. The movement looks flawless and smooth for the client and very smooth with almost no delay for other clients.
My player object is currently a finite state machine with the following states: idle, walking, attacking.
My general is approach is this:
- Sample Input
- Set the player's state
- On update read the sampled inputs and apply any changes to player world-state based on these inputs
- Send the input to the server along with an integer id and queue it
- On update on the server-side read any queued inputs and apply them one at a time, sending back updates with the player's world state and the packet id after each one (and of course broadcasting this to the other clients)
- Back on the client-side, when a packet comes back from the server apply it, drop all inputs with lower or equal packet id from the queue and reconcile the remaining ("future") inputs up to the client's present time
As I said, this works flawlessly for movement. However a major part of the design of combat is that the player freezes for a small period (500ms) following an attack.
On the client-side when an attack is initiated, the state is changed to 'attacking', the attack is executed, and the player is frozen. 500ms later the player unfreezes. On update the input is sent to the server following the same process as the movement. The server applies this input, freezes the player and sends back the data, all as expected.
edit: I've rewritten a large part of the question due to finding the underlying issue after some refactoring and debugging.
edit 2: Another problem, again with the timing of reconciliation is described below.
My issues occur in the reconciliation stage and all of them cause desync between the client and server. My timers are either:
Being created multiple times if the reconciliation process replays the attack initiation multiple times because of the timing of the updates from the server.
Being decremented for the same inputs multiple times if the same inputs are reconciled multiple times on separate server updates.
Finishing and being cleared on the client-side while still counting down on the server-side. Thus when an update comes back and reconciliation replays, there is no timer to count down and the player is not frozen.
The errors occur in the following cases:
Example 1 (First and second errors)
Client-side prediction:
- Inputs 1-15 are all movement packets.
- On input 16 an attack is initiated.
- Up to input 20 has been processed and sent but the timer has not reached 0 yet.
Server-side:
- Inputs 1-12 are processed and the world-state is sent back with a last input value of 12.
- Inputs 13-15 are processed and the world-state is sent back with a last input value of 15.
- Inputs 17-19 are processed and the world-state is sent back with a last input value of 19.
Client-side reconciliation:
- The world-state is received with a last input of 12. Inputs 13-15 are replayed but only contain movement. On input 16 an attack is initiated and the timer is set to 500ms. Inputs 17 to 20 are replayed, the timer has not yet reached zero.
- The world-state is received with a last input is 15. Input 16 is replayed and a second timer is now created (it shouldn't be). Additionally, the first timer ticks again erroneously. Inputs 17 to 20 are replayed, the first timer has now ticked twice for each of inputs 17 to 20 - it reaches zero and the player moves out of the attack state and begins moving putting the client and server out of sync. The second timer is ticking correctly but should technically not exist.
- The world-state is received with a last input of 19. The player is out of sync and experiences a warping back to where the prediction and the server said it should be at that point. The player is again in the attack state because this is enforced by the server. Input 20 is replayed and the second timer erroneously ticks again for input 20.
Example 2 (Third error)
Client-side prediction:
- Inputs 1-10 are all movement packets.
- On input 11 an attack is initiated.
- Up to input 16 has been processed with the timer finishing at input 15.
Server-side:
- Inputs 1-12 are processed and the world-state is sent back with a last input value of 12.
- Inputs 13-14 are processed and the world-state is sent back with a last input value of 14.
- Input 15-16 is processed and the world-state is sent back with a last input value of 16.
Client-side reconciliation
- The world-state is received with a last input of 12. On input 11 an attack is initiated and the timer is set to 500ms. Inputs 13-16 are replayed, with the timer reaching 0 at input 14.
- The world-state is received with a last input of 14. Since the prediction and previous reconciliation step both had the timer complete and the point where the attack is initiated has passed, there is now no timer and the player is not frozen. Packets 15-16 are replayed with the player moving erroneously on packet 15.
- The world-state is received with a last input of 16. No further input has occurred so reconciliation is not done here.
As you can see there are three distinct conditions that have to be satisfied in reconciliation for this to work properly:
- Timers should not be created multiple times for the same attack initiation.
- A timer should not tick for inputs It's already ticked on when inputs are being replayed. E.g. if the reconciliation replays inputs 14-20 and then 17-20, the timer should not tick twice for inputs 17-20.
- If the server replays from the middle of a timer which already completed on the client, the client should freeze the player for those frames and recreate/retick the timer from that point.
One solution I thought of is to send the time remaining on the timer(s) back from the server and let reconciliation use that to freeze the player & tick down the timer if needed. However this feels messy and could quickly cause problems once multiple timers come into the mix.
So, my actual question: How can I more effectively deal with timed events on reconciliation to fulfill the above conditions?
Much of my research on this topic included reading the usual recommended articles: Gaffer On Games Networked Physics series, Gabriel Gambetta's Fast-Paced Multiplayer series, and the Source Multiplayer Networking article. While they provided me with very significant help I am still not entirely sure how to apply the knowledge to the timing problem I've described.
There is also a related question I found since I searched again after finding my underlying issue: Client-side-prediction and Server-reconciliation with timing inputs.
I cannot unfortunately provide code at this time, however hopefully my description is enough to get a general idea of my process and give an answer. If not, I will gladly edit this post with more information as needed.
Thanks!