Multiplayer Improvements

<
>
October 5, 2021

I spent some time improving the initial multiplayer implementation.

Server holds own game state

Previously the server was just forwarding events. Now the server holds its own game state via CoreGame and applies all arriving events to that as well. This way it’s possible for the server to hold a mirror of the connected clients’ state.
The server now also runs the simulation on its state to keep in sync with the clients.

Games can be loaded by the server

I reworked loading and saving to not store the client view of the game ClientGame, but the core CoreGame.
This made it possible to also implement loading and saving for the server.

Initialize package

Since the server now has its own copy of the game state, it’s possible to serialize it and send it to newly connected clients.
This way clients that connect late will still be in sync.

Bundling events

Since every client now applies arriving events and also simulates the game, it’s very important that events are applied at the same time/tick for all clients. I therefore started bundling events ‘per tick’ and marking them accordingly.
This way clients can know when to apply those events and when to proceed with the simulation ticks.

Desync protection

To prevent clients from silently going out of sync I occasionally hash the server’s state and send that as checksum.
Clients can then check whether they went out of sync.

In detail and code

I added a new event type for events sent from the server:

pub enum ServerEvent {
    Game(GameEventBundle),
    Init(PersistedCoreGame),
}

Init is sent to newly connected clients as described above.
Game is the package sent per tick, looking like this:

pub struct GameEventBundle {
    pub for_tick: u64,
    pub events: Vec<GameEvent>,
    pub hash_check: Option<u64>,
}

As mentioned above it is marked with the tick it belongs to, contains the events the server received in that time frame and occasionally will contain the hash of the server’s state to allow clients to verify if they went out of sync.
The server logic for handling the events looks like this:

...
let for_tick = self.core.tick_count();
let mut events = Vec::new();
while let Ok(msg) = self.from_clients.try_recv() {
    events.push(msg)
}

let hash_check = if for_tick % 1000 == 0 {
    Some(self.core.game_hash())
} else {
    None
};

let bundle = GameEventBundle {
    for_tick,
    events,
    hash_check,
};

self.core.enqueue(bundle.clone());

let _ = self.to_clients.send(ServerEvent::Game(bundle));
...

It collects all the events that arrived since the last call via from_clients.try_recv(), creates the GameEventBundle with the core’s tick and sometimes hash and sends that to the connected clients and also applies it to its own game.
Clients then need to only tick the simulation once they received and applied the matching bundle.