I solved the problem on my own. My previous implementation of the networking engine made reconciliation impossible. The code I am using is private, though I plan on open sourcing the Box2D server/client-side prediction eventually. My solution is as follows:
- My game code is put into a separate custom Entity-Component-System, called NetEngine. The NetEngine only ticks with a fixed time step (the tick rate of my server, 33 ticks/second). Each tick increases a frame count, called frameNum, by 1.
- Inputs are collected by clients and put into an InputSnapshot, which is sent to the server. InputSnapshots are collected at the server's tick rate. Each snapshot has a frame number attached to it (representing the server's ticks since the game has started). The client also saves snapshots for reconciliation later.
- The NetEngine saves states of physics objects it controls (server = all entities, client = predicted entities) each frame. The frame number is attached to the snapshot. On the client, this is used for reconciliation, on the server it is used for lag compensation.
- The server ticks the NetEngine when all the InputSnapshots from all players on all clients have been received, and sends the client updates on entities. The frame number is attached for reconciliation and interpolation. (ticking the engine is as follows: First, set the inputs of all entities to the snapshots. Second, tick the NetEngine. Third, send clients entity updates. Repeat this process until the NetEngine can no longer tick, aka when there aren't enough InputSnapshots).
- Clients save PhysicsSnapshot for reconciliation.
- When a PhysicsSnapshot for the player's entity is received, check the player's saved InputSnapshots. If the earliest saved InputSnapshot has a corresponding PhysicsSnapshot (by frame number), we can begin reconciliation.
- Go through all entities and apply PhysicsSnapshots for the frame number found. Then, apply InputSnapshots for the player and tick the NetEngine. Keep applying InputSnapshots (and PhysicsSnapshots for entities that are not predicted) and ticking the NetEngine until the frame number has reached the current predicted frame number.
- Remove the InputSnapshot used for that round of reconciliation.
- Repeat the reconciliation process until no more reconciliation can be performed.
The client updates the NetEngine on its own (once every two frames, about, since my server runs about half as slow as my clients). In the client's Box2D simulation, all entities are kinematic except for predicted entities, which are dynamic. This allows the client to predict future states of its players, and check them again using the server's authoritative positions.
From what I have noticed, when this is implemented Box2D is fairly deterministic. I wouldn't bet on this, though, which is why I still run reconciliation.
It should also be noted that with the current setup (assuming interpolation is used), everything will render at 60fps except for the client's predicted players, which render at 33fps (the tick rate of the server). The fix for this, however, is to predict the next frame, and interpolate between the two frames. This fixes the client's players rendering at 33fps, and everything is smooth.
I did not describe lag compensation (I haven't implemented it myself), however there are many articles describing how. Essentially, the server keeps track of all PhysicsSnapshots and InputSnapshots, and because of this can rewind the state of the world in such a way that the server can recreate whichever client it chooses at any given time. The server is then able to determine if certain events have taken place (such as a player shooting another player, for example).
I apologize for the messy explanation, it's hard to describe server reconciliation in such a short response. However, if anybody has any questions regarding my method I'll be happy to answer them to the best of my ability.