Intermediate Modding - RighteousRyan1/TanksRebirth GitHub Wiki

Custom Netcode

There are things in the game that, in multiplayer, need syncing across the net. Things that are rendered to your window are examples of client-sided processes. Things that are necessary for everyone to see in the same spot in order for gameplay to appear properly to each player are server-sided processes.

In Tanks Rebirth, the player to create the server is also the server. Information is sent back and forth between each client and the host's computer. In order to synchronize data between the server and all of its clients, we must send small snippets of ordered data through networking sockets, which are called packets.

Basics of Packets

TanksRebirth.Net has all of the code centered around networking and synchronized data is stored within this namespace/directory.

NetPlay.cs contains the handling of packets. Packets are handled when:

  1. Packets are arriving to the server (if this client is also the server/host)
  2. Packets are arriving to this client

Netcode in this game makes use of LiteNetLib, a lightweight networking library.

In order to construct your own packet, you must create your own NetDataWriter.

NetDataWriter message = new();

With this writer initialized, we can now send our own data through the server. By using message.Put, we can use pretty much all primitive data types, and then some. If we need to send, for example, a number, we can just Put an integer into our message.

In order to differentiate what information is being sent, developers use identifiers for each packet that exists within a codebase, such as needing to differentiate between requesting a tank's properties or wanting the information of a shell somewhere on the screen. Without identification, our processor doesn't know how to determine what the other client wants.

Sending and Receiving Packets

As stated above, message.Put is how you put data into a packet, but how do we properly transfer this information between clients?

First, we need to create our own unique packet identification so our packets not only don't overlap with Rebirth's own packet identifiers, but also do not clash with any other mods that may have their own netcode. To do this, we need a line akin to the following when our mod is loaded. Declare an int, preferably static, as a field within the class you wish to contain this information.

public override void OnLoad() {
    MyTestPacket = PacketID.AddPacketId(nameof(MyTestPacket));
}

And on top of that, don't forget to remove it when the mod is unloaded, this is very important too.

public override void OnUnload() {
    PacketID.Collection.TryRemove(MyTestPacket);
}

With that, we are ready to hook into the events in the case of a received packet. Add these two lines somewhere within your OnLoad()

NetPlay.OnReceiveClientPacket += NetPlay_OnReceiveClientPacket;
NetPlay.OnReceiveServerPacket += NetPlay_OnReceiveServerPacket;

If you let autofill place the subscribed event methods in your code (pressing TAB after typing +=), you should see the following two things.

private void NetPlay_OnReceiveClientPacket(int packet, LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, LiteNetLib.DeliveryMethod deliveryMethod) {
    throw new NotImplementedException();
}
private void NetPlay_OnReceiveServerPacket(int packet, LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, LiteNetLib.DeliveryMethod deliveryMethod) {
    throw new NotImplementedException();
}

Each of the parameters given within these methods are very useful to determine how data should be sent around.

int packet: The identifier of the sent packet. Used to determine which code to use on the receiving end.

NetPeer peer: The network peer (either a client or the server) that sent this data.

NetPacketReader reader: This is the object containing the sequence of data that was sent. Data must be read identically to the order it was sent in.

DeliveryMethod deliveryMethod: Is the manner in which the data was sent. We will go over this thoroughly further down this guide.

In order to construct a proper packet, we have to create our NetDataWriter as shown in the previous segment. But, before we create anything or put data in our packet, we have to be careful to not do any networking magic unless our client is connected to a server. Thankfully, Rebirth has you covered, allowing you to check if the current game client is connected to a server via a simple Client.IsConnected() check. In the following code block, comments will describe the process of what is happening, in order.

// This method can be named anything you wish.
// The parameters are what is passed into the packet further down the method.
public static void SendTestPacket(string text) {
    // If the game client is not connected to a server, we don't bother running the future lines.
    if (!Client.IsConnected()) return;

    // Constructs a NetDataWriter, which will be our packet.
    NetDataWriter message = new();

    // Here we put the data that goes into our packet of data. We send the packet type first, and not by choice.
    // Tanks Rebirth automatically reads an integer as the first piece of data in the packet's data stream, which becomes the 'int packet' above.
    message.Put(MyTestPacket);
    // This is the string of text that will be sent second in the two pieces of data sent in this packet.
    message.Put(text);

    // This is how our data is sent to the server. We end up handling the data again within the scope of the server.
    // Delivery methods will be covered after the next couple of code blocks.
    Client.NetClient.Send(message, LiteNetLib.DeliveryMethod.ReliableOrdered);
}

Now that we have this code, we have to manage what happens on the server when it receives the code from the final line in that code block.

private void NetPlay_OnReceiveServerPacket(int packet, LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, LiteNetLib.DeliveryMethod deliveryMethod) {
    // We create another packet since we are creating another sequence of data that is identically created and sequenced back to the client(s).
    NetDataWriter message = new();
    // The first thing we put in our packet is the packet identifier, since the client(s) also needs to identify how the data should be processed.
    message.Put(packet);
    // Use a check to verify that the packet is the packet we want before we process data.
    if (packet == MyTestPacket) {
        // Since we are sending identical data to each client, we just put the same data back into the new NetDataWriter.
        // This will send the 'text' parameter from "SendTestPacket" from the method in the previous code snippet.
        message.Put(reader.GetString());
        // Using this method, we send the data within the newly created NetDataWriter to each client, but excluding the client who sent the data.
        // The final parameter, 'peer', is not necessary, but is useful. You only need to add this if you don't want any data being sent back to the sender.
        Server.NetManager.SendToAll(message, LiteNetLib.DeliveryMethod.ReliableOrdered, peer);
    }
}

Finally, we have how the data is processed on the client after a trip to the server. This is where the netcode is handled, and the results are visible.

private void NetPlay_OnReceiveClientPacket(int packet, LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, LiteNetLib.DeliveryMethod deliveryMethod) {
    // Again, we check if the packet is what we want it to be before we process the data.
    if (packet == MyTestPacket) {
        // Since the packet identifier has already been read by Rebirth, we only need to get a string from the packet.
        // Because of this, 'reader.GetString()' just returns the message we wanted to be sent across the server.
        // When this code is called, it will print the text you wanted in beige.
        ChatSystem.SendMessage(reader.GetString(), Color.Beige);
    }
}

With all of this code, all we need to do is call SendTestPacket(/* your string here */) and other players will see the message you put in.

Delivery Methods

Finally, to cover delivery methods.

Delivery methods are quite important in the realm of netcode because if we ensure that the data we send is reliably ordered to how it was constructed, but it can easily bottleneck the delivery process. As a rule of thumb, the brief descriptions of the following DeliveryMethods are useful for knowing when to apply which to a situation.

ReliableOrdered: This is information that is crucial to arrive, and in the correct order. For example, packets that are sent inconsistently and are read at specific points in gameplay will most likely need this delivery method. Packets will not suffer from duplication in the case of poor internet stability. This is the slowest but most reliable method of delivery.

Sequenced: This is information that won't ruin gameplay or information if it doesn't successfully arrive to the server/other clients. This is a marginally faster method of delivery at the risk of information being dropped. If this information arrives, it will be ordered, and will not be duplicated.

Unreliable: This is information that is generally synchronized with each client on a most-if-not-all-frames basis. Things such as consistently updating parts of gameplay are why you use Unreliable. This is the fastest method of delivery, but the risk of packet malfunction is the highest. Packets using this delivery method can be duplicated, unordered, or dropped in the case of poor internet stability.