Since I have gotten a ton of help from a lot of people on this forum over the years, and especially hplus0603, I figured I could give some back and provide the code for the system for tick synchronization and simulation that I have come up with. This is by no means anything revolutionary or different from what everyone else seem to be doing, but maybe it will provide a good example for people learning. For reference I am using this for an FPS game.
First some basic numbers:
My simulation runs at 60 ticks per second on both the server and client. While it could be possible to run the server at some even multiplier of the client (say 30/60 or 20/60 or 30/120, etc.) I have chosen to keep both simulations at the same rate for simplicity's sake.
Data transmission rates are also counted in ticks and happen directly after a simulation tick, the clients send data to the server 30 times per second (or every 2nd tick), the server sends data to the clients 20 times per second (or every 3rd tick).
Here are first some constants we will use throughout the pseudo code:
#define SERVER_SEND_RATE 3
#define CLIENT_SEND_RATE 2
#define STEP_SIZE 1f/60f
I use the canonical fixed simulation/variable rendering game loop, which looks like this (C-style pseudo code):
float time_now = get_time();
float time_old = time_now;
float time_delta = 0;
float time_acc = 0;
int tick_counter = 0;
int local_send_rate = is_server() ? SERVER_SEND_RATE : CLIENT_SEND_RATE;
int remote_send_rate = is_server() ? CLIENT_SEND_RATE : SERVER_SEND_RATE;
while (true) {
time_now = get_time();
time_delta = time_now - time_old;
time_old = time_now;
if (time_delta > 0.5f)
time_delta = 0.5f;
time_acc += time_delta;
while (time_acc >= STEP_SIZE) {
tick_counter += 1;
recv_packets(tick_counter);
step_simulation_forward(tick_counter);
if ((tick_counter % local_send_rate) == 0) {
send_packet(tick_counter);
}
time_acc -= STEP_SIZE;
}
render_frame(time_delta);
}
This deal with the local simulation and the local ticks on both the client and the server, now how do we deal with the remote ticks? That is how do we handle the servers ticks on the client, and the clients ticks on the server.
The first key to the puzzle is that the first four bytes in every packet which is sent over the network contains the local tick of the sender, and it's then received into a struct which looks like this:
struct Packet {
int remoteTick;
bool synchronized;
char* data;
int dataLength;
Packet* next;
}
Exactly what's in the "data" pointer is going to be very game specific, so there is no point in describing it. The key is the "ticks" field, which is the local tick of the sending side at the time the packet was constructed and put on the wire.
Before I show the "receive packet" function I need to show the Connection struct which encapsulates a remote connection, it looks like this:
struct Connection {
int remoteTick = -1; // this is not valid C, just to show that remoteTick is initialized to -1
int remoteTickMax = -1;
Packet* queueHead;
Packet* queueTail;
Connection* next;
// .. a ton of more fields for sockets, ping, etc. etc.
}
The Connection struct contains two fields which are important too us (and like said in the comment, a ton more things in reality which are not important for this explanation): remoteTick, this is the estimated remote tick of the remote end of this connection (the first four bytes we get in each packet). queueHead and queueTail which forms the head/tail pointers of the currently packets in the receive buffer.
So, when we receive a packet on both the client and the server, the following code executes:
void recv_packet(Connection c, Packet p) {
// if this is our first packet (remoteTick is -1)
// we should initialize our local synchronized remote tick
// of the connection to the remote tick minus remotes send rate times 2
// this allows us to stay ~2 packets behind the remote on avarage,
// which provides a nice de-jitter buffer. The code for
// adjusting our expected remoteTick is done somewhere else (shown
// further down)
if (c->remoteTick == -1) {
c->remoteTick = p.remoteTick - (remote_send_rate * 2);
}
// deliver data which should be "instant" (out of bounds
// with the simulation), such as: reliable RPCs, ack/nack
// to the remote for packets, ping-replies, etc.
deliver_instant_data(c, p);
// insert packet on the queue
list_insert(c->queueHead, c->queueTail, p);
}
Now, on each connection we will have a list of packets in queueHead and queueTail, and also a remoteTick which gets initialized to the first packets remoteTick value minus how large of a jitter-buffer we want to keep.
Now, inside the step_simulation_forward(int tick) function which moves our local simulation forward (the objects we control ourselves), but we also integrate the remote data we get from our connections and their packet queues. First lets just look at the step_local_simulation function for reference (it doesn't contain anything interesting, but just want to show the flow of logic):
void step_simulation_forward (int tick) {
// synchronize/adjust remoteTick of all our remote connetions
synchronize_connection_remote_ticks();
// de-queue incomming data and integrate it
integrade_remote_simulations();
// move our local stuff forward
step_local_objects(tick);
}
The first thing we should do is to calculate the new synchroznied remoteTick of each remote connection. Now this ia long function, but the goals are very simple:
To give us some de-jittering and give us smooth playback, we want to stay remote_send_rate * 2 behind the last received packet. If we are closer to the received packet then < remote_send_rate or further away then remote_send_rate * 3, we want to adjust to get closer. Depending on how far/close we are we adjust one or a few frames up/down or if we are very far away we just reset-completely.
void synchronize_connection_remote_ticks() {
// we go through each connection and adjust the tick
// so we are as close to the last packet we received as possible
// the end result of this algorithm is that we are trying to stay
// as close to remote_send_rate * 2 ticks behind the last received packet
// there is a sweetspot where our diff compared to the last
// received packet is: > remote_send_rate and < (remote_send_rate * 3)
// where we dont do any adjustments to our remoteTick value
Connection* c = connectionList.head;
while (c) {
// increment our remote tick with one (we do this every simulation step)
// so we move at the same rate forward as the remote end does.
c->remoteTick += 1;
// if we have a received packet, which has not had its tick synchronized
// we should compare our expected c->remoteTick with the remoteTick of the packet.
if (c->queueTail && c->queueTail->synchronized == false) {
// difference between our expected remote tick and the
// remoteTick of the last packet that arrived
int diff = c->queueTail->remoteTick - c->remoteTick;
// our goal is to stay remote_send_rate * 2 ticks behind
// the last received packet
// if we have drifted 3 or more packets behind
// we should adjust our simulation slightly
if (diff >= (remote_send_rate * 3)) {
// step back our local simulation forward, at most two packets worth of ticks
c->remoteTick += min(diff - (remote_send_rate * 2), (remote_send_rate * 4));
// if we have drifted closer to getting ahead of the
// remote simulation ticks, we should stall one tick
} else if (diff >= 0 && diff < remote_send_rate) {
// stall a single tick
c->remoteTick -= 1;
// if we are ahead of the remote simulation,
// but not more then two packets worth of ticks
} else if (diff < 0 && abs(diff) <= remote_send_rate * 2) {
// step back one packets worth of ticks
c->remoteTick -= remote_send_rate;
// if we are way out of sync (more then two packets ahead)
// just re-initialize the connections remoteTick
} else if (diff < 0 && abs(diff) > remote_send_rate * 2) {
// perform same initialization as we did on first packet
c->remoteTick = c->queueTail->remoteTick - (remote_send_rate * 2);
}
// only run this code once per packet
c->queueTail->synchronized = true;
// remoteTickMax contains the max tick we have stepped up to
c->remoteTickMax = max(c->remoteTick, c->remoteTickMax);
}
c = c->next;
}
}
The last piece of the puzzle is the function called integrade_remote_simulations, this function looks as the packets available in the queue for each connection, and if the current remote tick of the connection is >= remote_tick_of_packet - (remote_send_rate - 1).
Why this weird remote_tick comparison? Because if the remote end of the connection sends packets every remote_send_rate tick, then each packet contains the data for remote_send_rate ticks, which means the tick number of the packet itself, and then the ticks at T-1 and T-2.
void integrade_remote_simulations() {
Connection* c = connectionList.head;
while (c) {
while (c->queueHead && c-remoteTick >= c->queueHead->remoteTick - 2) {
// integrate data into local sim, how this function looks depends on the game itself
integrade_remote_data(c->queueHead->data);
// remove packet
list_remove_first(c->queueHead, c->queueTail);
}
c = c->next;
}
}
I hope this helps someone