o
odinpkg.dev
packages / library / odin_rollback

odin_rollback

afd0e4alibrary

Rollback Netcode in the Odin Programming Language

MIT · updated 7 months ago

Rollback Netcode

This is my take on Rollback Netcode. It consists of an authoritative server, and clients that can join the server. It uses ENet under the hood for reliable UDP networking.

Note

This package is more of an experiment than something ready for production use. There are likely a few bugs here and there, and edge cases that I did not consider. There are also probably smarter ways to do certain things as well that I haven't thought of.

How to Use

Drop the package into your project, import it, and fill out a V Table to let it know how to handle your simulation.

VTable :: struct {
	// Allocate and initialize a new simulation state
	create_state: proc(allocator: runtime.Allocator) -> (state: rawptr, err: runtime.Allocator_Error),

	// Deinitialize and free any allocations of a simulation state
	destroy_state: proc(state: rawptr),

	// Copy the simulation state from src to dst, this needs to produce an
	// identical deterministic replica
	copy_state: proc(dst, src: rawptr),

	// Copy the state of a player from src_state to dst_state (simulation states).
	// This is only used in the client's visual prediction correction as of right
	// now, so it really only needs to be a visual copy
	copy_player: proc(dst_state, src_state: rawptr, id: Player_Id),

	// This function is responsible for serializing, deserializing, and hashing
	// your simulation state. It needs to account for all of the state that is
	// important to sync between simulation instances
	serialize: proc(serializer: rlb.Serializer, state: rawptr),

	// Advance a single physics timestep in a simulation. This needs to be a fixed
	// and deterministic timestep
	step: proc(state: rawptr),

	// Apply the input of a specific player to the simulation
	apply_input: proc(state: rawptr, id: Player_Id, input_data: []byte),
}

Check out the example for more details.

What is Rollback Netcode?

If care is taken to make sure that simulation logic is deterministic, then remote syncing of those simulations can be achieved by simply exchanging player input, rather than simulation state.

Rollback Netcode takes advantage of this, and uses input prediction to run the local simulation ahead of time before receiving comprehensive input from all of the players. This means that what the local player sees is not actually exactly what happened yet, just a very good guess.

If the simulation later finds out that one of the predicted inputs wasn't correct, it then 'rolls back' to the state it was in before it received that input and corrects itself.

My Approach

Now, I'm a little ignorant on how others approach this problem exactly as I haven't done much research. I kind of wanted to just dive in and figure out how to do it myself and see what happens. As such, I think my approach diverges a bit from normal Rollback implementations, and may not even fit exactly into the same category in some ways.

I believe that most Rollback implementations are peer to peer. This means that everyone connected is an equally permissioned participant, and no one has the authority to say what did or didn't happen. This has the benefit of not needing a server in the middle and reduces latency, but loses the benefits of having an authoritative server.

I, however, elected to use a server-based implementation. The benefit of this approach is that the server can act as a bookkeeper. It has the final say in who performed what input on which frame, and runs an authoritative simulation that serves as the ground truth of what happened. The benefit of this is that it can use this authoritative simulation to sync clients that join late, and also facilitate desync detection and correction by sending clients authoritative hashes that they can compare their own simulation state with.

There are a number of different ways you can go about trying to sync the server and the clients, but this is the best way I've been able to come up with so far.

The Server

The server holds a single authoritative simulation that advances frame by frame in realtime. The clients send the server their desired input for a desired frame in the future, and the server receives this input and stores it in a registry. As the server's simulation advances, it uses input from the registry and then discards it to save space.

The server does not wait, if a client's input for a frame doesn't arrive in time, the server uses the last known input for that client instead and moves on. This avoids the server pausing and waiting for input of slow or disconnected clients.

After the server receives input from a client, as long as it isn't too late, it will use it and pass it on to other clients for them to use in their own simulation.

Every time the server frame-advances its simulation, it sends a message to all clients that it did so, paired with a hash of its resulting simulation state after the frame-advancement. This way, clients know exactly when the server has advanced its simulation, and can check if their own simulation state has desynced after they advance as well.

The Clients

The clients run two simulations, the ground truth, and a visual prediction. The clients receive frame-advancement and input messages from the server, which thanks to ENet are reliably ordered. This means that as long as the simulation logic is deterministic, the clients can maintain their own ground truth simulation that is, in theory, a perfect replica of the server's.

If you were to just call it a day and render this ground truth simulation, it would potentially be very jerky and have noticeable input delay for the player. For this reason, the clients generate a visual prediction that is meant to be rendered and provide the player with a smoother experience.

The clients hold their own registry of player input in a similar fashion to the server. Frame by frame, in realtime, the clients will get their input from the player, send it to the server, and then recalculate their visual prediction by starting at the ground truth and readvancing to their predicted frame number.

A problem that arises with generating a visual prediction, is that the further in the future from the ground truth you predict other players' states, the more jarring and jerky corrections of their state will be when their predicted input was incorrect.

I believe the normal way to deal with this in a Rollback scenario is to have a flat input delay, which essentially means limiting how far in the future to render the visual state in an attempt to make it less jarring. This flat input delay would need to be tuned based on how good or bad everyone's connection is.

The approach I came up with, is to not use a flat frame delay, but rather individually render other players in the past (from the perspective of the local player's own visual prediction) scaled by how poor their connection is. This has the effect of smoothing other players' states visually, at the expense of them being rendered in the past. The client.flat_input_delay field can be tuned if more traditional Rollback behavior is desired.

Serialization, Deserialization, and Hashing

In order to facilitate syncing over network, input and simulation state need to be serialized. This functionality is provided by a serializer of the LBP variety, inspired by jakubtomsu's LBP serializer, with my own added twist that it also handles hashing.

Basic Serialization

Serialization functions work with a generic Serializer, which is just an io.Stream that knows whether or not it is reading or writing and stores an allocator in case it needs to allocate.

The generic serializer can be specialized into a Reader, Writer, or Hasher, which all use the same functions.

The main way to use the serializer is with a serialize proc that handles all of the basic types (hopefully, let me know if I missed anything). The types are serialized down to their basic binary representation using little-endian and 64 bit values when platform-ambiguous.

Type information is not automatically serialized, so you need to have knowledge of what was serialized to be able to deserialize it.

You need to make special consideration for how pointy types (pointers, slices, dynamic arrays, strings, maps, etc) are handled. If you serialize a pointy type, it will be allocated when you deserialize it, which means you need to clean it up later.

With the serialize proc, you need to manually write your serialization functions of complicated types that can't be resolved without the use of Runtime Type Information. While verbose, this has the benefit of being fast.

package main

import "core:fmt"
import rlb "rollback"

Foo :: struct {
	a: int,
	b: f32,
	c: bool,
}

serialize_foo :: proc(s: rlb.Serializer, foo: ^Foo) -> bool {
	rlb.serialize(s, &foo.a) or_return
	rlb.serialize(s, &foo.b) or_return
	rlb.serialize(s, &foo.c) or_return
	return true
}

main :: proc() {
	input: Foo
	input.a = 3
	input.b = 382.13
	input.c = true

	writer: rlb.Writer
	rlb.init_writer(&writer)
	defer rlb.destroy_writer(&writer)

	serialize_foo(writer, &input)

	output: Foo

	reader: rlb.Reader
	rlb.init_reader(&reader, rlb.to_string(&writer))

	serialize_foo(reader, &output)

	fmt.println(output)
}

Runtime Type Information-Based Serialization

There is also an opt-in RTTI-based serialization function that handles the more complicated types, like structs, unions, soa, slices of structs, maps of structs, etc.

You can make use of struct field tags to tell the serialize_rtti function if you want to ignore certain struct fields.

Caution

This is much more complicated, and I have limited experience with RTTI, so proceed at your own risk (there could be bugs or memory issues).

package main

import "core:fmt"
import rlb "rollback"

Foo :: struct {
	a: int,
	b: f32,
	c: bool,
	d: u64 `rlb:"-"`, // Explicitly ignore a field
	e: map[int]int,   // Pointy types will be allocated when deserialized
}

destroy_foo :: proc(foo: Foo) {
	delete(foo.e)
}

main :: proc() {
	input: Foo
	input.a = 3
	input.b = 382.13
	input.c = true
	input.d = 382719382
	for i in 0 ..< 4 {
		input.e[i] = i
	}
	defer destroy_foo(input)

	writer: rlb.Writer
	rlb.init_writer(&writer)
	defer rlb.destroy_writer(&writer)

	rlb.serialize_rtti(writer, &input)

	output: Foo

	// The map will be allocated and needs to be cleaned up
	defer destroy_foo(output)

	reader: rlb.Reader
	rlb.init_reader(&reader, rlb.to_string(&writer))

	rlb.serialize_rtti(reader, &output)

	fmt.println(output)
}

Deterministic Randomness

Odin provides a random number generator that is passed around via its context system. This random number generator can be reset to a seed to provide deterministic behavior.

My solution to providing deterministic randomness in a Rollback scenario, is to leverage this system by storing a checkpoint seed at the beginning of every frame. This checkpoint seed can then be used at the beginning of the next frame to restore the random number generator to a deterministic state, regardless of how it was used between simulation frames.

A Note About Box2D

I have been unable to get Box2D to sync properly with this package. I was able to get it working for small simulations, but as object count increased, determinism seemed to fail. I think maybe Box2D has some internal state that needs to be synced to enable Rollback determinism, but I don't know enough about it to say for sure. There will probably be similar problems with other physics engines.