Unity - How to Send Data With LiteNetLib
Continuing off the previous post about how to build a basic server / client set up with LiteNetLib it’s time to talk about sending data. Because, well, a network set up that doesn’t actually send any data is kind of useless.
As we already know, we listen for incoming messages from the network via an IEventListener
that we pass to our NetManager
. IEventListener
contains an event called NetworkReceiveEvent
which is triggered anytime the network receives data from another connection. (If we were a server this would be one of the clients sending us something.)
In the NetworkReceiveEvent
delegate we’re given a few parameters. The NetPeer
that the message came from, a NetDataReader
for reading the incoming data, and the DeliveryMethod
of how it was sent (see LiteNetLib’s Delivery Methods).
public void HandleNetworkRecieveEvent(NetPeer sender, NetDataReader data, DeliveryMethod deliveryMethod) {
}
NetDataReader
provides us with a lot of great helpers to interpret the data received, such as GetByte()
, GetInt()
, or even GetStringArray()
but what do we do if we don’t know what kind of data was sent?
One option would be to ensure our data is always sent with a type flag as the first byte which we could then use to recreate the original object but then our handler would grow linearly as the number of packet types increases.
public void HandleNetworkRecieveEvent(NetPeer sender, NetDataReader data, DeliveryMethod deliveryMethod) {
PacketType = (PacketType)data.PeekByte()
switch(PacketType) {
case PacketType.Foo
...
case PacketType.Bar
...
case PacketType.Baz
...
case PacketType.Cat
...
case PacketType.Dog
...
// And so on...
}
}
Enter the NetPacketProcessor
On the other hand, we could offload some of this logic to the NetPacketProcessor
and leave it up to the processor to rebuild our incoming data. This convenience does come with a price though. The NetPacketProcessor
will add 64 bits (8 bytes) to the beginning of each message we send. There’s no doubt that there are some situations where every bit matters and this may be a deal breaker, but for most applications it’s a reasonable price to pay.
Let’s define a packet that we want to send over the network.
public class FooPacket {
public int NumberValue { get; set; }
public string StringValue { get; set; }
}
Client.cs
and Server.cs
from the previous section will be reused to reduce redundancy.
First up, we’re going to modify the server to send a FooPacket
to the newly connected client in PeerConnectedEvent
. To do this we’ll need to add a NetPacketProcessor
as a field on the server.
public class Server : MonoBehaviour {
EventBasedNetListener netListener;
NetManager netManager;
NetPacketProcessor netProcessor; // Add this
// Start is called before the first frame update
void Start() {
netListener = new EventBasedNetListener();
netManager = new NetManager(netListener);
netProcessor = new NetPacketProcessor(); // Don't forget to initialize it
netManager.Start(9050);
netListener.ConnectionRequestEvent += (request) => {
request.Accept();
};
netListener.PeerConnectedEvent += (client) => {
Debug.LogError($"Client connected: {client}");
};
}
// Update is called once per frame
void Update() {
netManager.PollEvents();
}
}
Then in the PeerConnectedEvent
delegate we’ll add a new call to NetPacketProcessor.Send()
.
netListener.PeerConnectedEvent += (client) => {
Debug.LogError($"Client connected: {client}");
netProcessor.Send(client, new FooPacket() { NumberValue = 1, StringValue = "Test" }, DeliveryMethod.ReliableOrdered);
};
If we wanted to send a FooPacket
to every client connected to the server when a new client connects we could achieve this by using the following line instead:
netManager.SendToAll(netProcessor.Write(new FooPacket() { NumberValue = 3, StringValue = "Cat"}), DeliveryMethod.ReliableOrdered);
With the server set up to send a FooPacket
to the client upon successful connection, we need to update the client to listen for the FooPacket
. We’ll do this by adding a NetPacketProcessor
field to the Client
.
public class Client : MonoBehaviour {
EventBasedNetListener netListener;
NetManager netManager;
NetPacketProcessor netPacketProcessor; // New field
void Start() {
netListener = new EventBasedNetListener();
netPacketProcessor = new NetPacketProcessor(); // Same thing to initalize as the server
netListener.PeerConnectedEvent += (server) => {
Debug.LogError($"Connected to server: {server}");
};
netManager = new NetManager(netListener);
netManager.Start();
netManager.Connect("localhost", 9050);
}
// Update is called once per frame
void Update() {
netManager.PollEvents();
}
}
Then we’ll set it up so the packet processor reads all incoming data. Put this in Start()
with the other initialization logic.
netListener.NetworkReceiveEvent += (server, reader, deliveryMethod) => {
netPacketProcessor.ReadAllPackets(reader, server);
};
And lastly, we need to tell the packet processor to notify us when a FooPacket
comes in. This will also be done in Start()
.
netPacketProcessor.SubscribeReusable<FooPacket>((packet) => {
Debug.Log("Got a foo packet!");
Debug.Log(packet.NumberValue);
});
Running the server and client we can see that the FooPacket
is sent to the client from the server and contains the correct NumberValue
of 1.
Limitations
As noted by the wiki page the NetPacketProcessor
has several limitations.
Properties must have public getters / setters or classes / structs implement INetSerializable
.
// Bad!
public class BarPacket {
public string Key { get; private set; }
}
// Good
public class BarPacket : INetSerializable {
public string Key { get; private set; }
public void Serialize(NetDataWriter writer) {
writer.Put(Key);
}
public void Deserialize(NetDataReader reader) {
Key = reader.GetString();
}
}
Nested types are not supported. The following types for properties are supported:
byte sbyte short ushort int uint long ulong float double bool string char IPEndPoint
byte[] short[] ushort[] int[] uint[] long[] ulong[] float[] double[] bool[] string[]
However, there are two workarounds. The first option is to register the nested type with a static Serialize
and Deserialize
method to parse them. This is useful for types that we do not have control over such as Vector3
in Unity.
public class FakePacket {
public Vector3 Position { get; set; }
}
public class Vector3Utils {
public static void Serialize(NetDataWriter writer, Vector3 vector) {
writer.Put(vector.x);
writer.Put(vector.y);
writer.Put(vector.z);
}
public static Vector3 Deserialize(NetDataReader reader) {
return new Vector3(reader.GetFloat(), reader.GetFloat(), reader.GetFloat());
}
}
netPacketProcessor = new NetPacketProcessor();
netPacketProcessor.RegisterNestedType(Vector3Utils.Serialize, Vector3Utils.Deserialize);
The second option is for the nested type to implement INetSerializable
. This is the better choice to use when we have control over the type definition.
public class CatPacket {
public Cat Cat { get; set; }
}
public class Cat : INetSerializable {
public string Name { get; set; }
public int Age { get; set; }
public void Serialize(NetDataWriter writer) {
writer.Put(Name);
writer.Put(Age);
}
public void Deserialize(NetDataReader reader) {
Name = reader.GetString();
Age = reader.GetInt();
}
}
netPacketProcessor = new NetPacketProcessor();
netPacketProcessor.RegisterNestedType<Cat>(() => new Cat()); // We need to pass the constructor when it's not a struct.