Seen as there are not many tutorials or documentation for the library, I've decided to write a guide that you can follow to get started with setting up LiteNetLib for a basic multiplayer game.
If you're not familiar with it, LiteNetLib is a lightweight C# networking library for game development that implements a reliable UDP protocol. It has been successfully used in commercial games such as 7 Days to Die.
The code snippets in this article will be using Godot, but you can easily adapt the code for Unity or whatever other platform you're using.
Client and Server
Let's start with basic classes for the client and the server. If you're using Godot, you might want to add the client to AutoLoad, to keep it loaded between scenes.
You'll also want to create a separate scene for the server class. CLI Godot can be used for running two instances of your game at the same time. cd
into your project's directory and run godot-mono scenes/Server.tscn
.
using Godot;
using System;
using LiteNetLib;
using LiteNetLib.Utils;
public class Client : Node, INetEventListener {
private NetManager client;
public void Connect() {
client = new NetManager(this) {
AutoRecycle = true,
};
}
public override void _Process(float delta) {
if (client != null) {
client.PollEvents();
}
}
// ... INetEventListener methods omitted
}
using Godot;
using System;
using LiteNetLib;
using LiteNetLib.Utils;
public class Server : Node, INetEventListener {
private NetManager server;
public override void _Ready() {
server = new NetManager(this) {
AutoRecycle = true,
};
}
public override void _Process(float delta) {
server.PollEvents();
}
// ... INetEventListener methods omitted
}
Things to note:
- I am implementing
INetEventListener
on the classes themselves. Hopefully your code editor can automatically generate the interface implementation, but if not, you can create aEventBasedNetListener
and pass it toNetManager
instead, as per the README example. - You can keep the methods mostly empty for now, as they're all callbacks.
- We check if
client != null
since it usually won't be started immediately, unlike the server.
The next step is connecting the client to the server:
private NetPeer server;
public void Connect() {
// ...
client.Start();
GD.Print("Connecting to server");
client.Connect("localhost", 12345, "");
}
public void OnPeerConnected(NetPeer peer) {
GD.Print("Connected to server");
server = peer;
}
public override void _Ready() {
// ...
GD.Print("Starting server");
server.Start(12345);
}
public void OnConnectionRequest(ConnectionRequest request) {
GD.Print($"Incoming connection from {request.RemoteEndPoint.ToString()}");
request.Accept();
}
Things to note:
- You'll probably want to limit the number of peers that can connect to the server eventually. This can be done by checking
server.ConnectedPeerCounts
and rejecting requests when necessary. - You can add a "password" (connection key) to the server, which is useful for things like blocking players with outdated clients from joining the server. Take a look at the parameters of
client.Connect
andconnection.AcceptIfKey
.
Communicating with Packets
To communicate between client and server, we must first define some packets:
public class JoinPacket {
public string username { get; set; }
}
public class JoinAcceptPacket {
public PlayerState state { get; set; }
}
public struct PlayerState : INetSerializable {
public uint pid;
public Vector2 position;
public void Serialize(NetDataWriter writer) {
writer.Put(pid);
writer.Put(position);
}
public void Deserialize(NetDataReader reader) {
pid = reader.GetUInt();
position = reader.GetVector2();
}
}
public class ClientPlayer {
public PlayerState state;
public string username;
}
public class ServerPlayer {
public NetPeer peer;
public PlayerState state;
public string username;
}
The packet classes are automatically serialized by LiteNetLib. Only properties are serialized though, not fields, so the { get; set; }
is necessary. You can also define a struct instead and implement INetSerializable
manually, as it was done for PlayerState
. This is useful since you also get the copy semantics of a struct.
We are still missing something, however. You might have noticed the reader.GetVector2
and writer.Put(Vector2)
functions don't actually exist. LiteNetLib can serialize most basic data types by default, but not Godot's Vector2
, since it's a struct. Let's implement extension methods for NetDataWriter
and NetDataReader
:
public static class SerializingExtensions {
public static void Put(this NetDataWriter writer, Vector2 vector) {
writer.Put(vector.x);
writer.Put(vector.y);
}
public static Vector2 GetVector2(this NetDataReader reader) {
return new Vector2(reader.GetFloat(), reader.GetFloat());
}
}
Now let's start sending packets:
private NetDataWriter writer;
private NetPacketProcessor packetProcessor;
private ClientPlayer player = new ClientPlayer();
public void Connect(string username) {
player.username = username;
writer = new NetDataWriter();
packetProcessor = new NetPacketProcessor();
packetProcessor.RegisterNestedType((w, v) => w.Put(v), reader => reader.GetVector2());
packetProcessor.RegisterNestedType<PlayerState>();
packetProcessor.SubscribeReusable<JoinAcceptPacket>(OnJoinAccept);
// ...
}
public void SendPacket<T>(T packet, DeliveryMethod deliveryMethod) where T : class, new() {
if (server != null) {
writer.Reset();
packetProcessor.Write(writer, packet);
server.Send(writer, deliveryMethod);
}
}
public void OnJoinAccept(JoinAcceptPacket packet) {
GD.Print($"Join accepted by server (pid: {packet.state.pid})");
player.state = packet.state;
}
public void OnPeerConnected(NetPeer peer) {
// ...
SendPacket(new JoinPacket { username = player.username }, DeliveryMethod.ReliableOrdered);
}
public void OnNetworkReceive(NetPeer peer, NetPacketReader reader, DeliveryMethod deliveryMethod) {
packetProcessor.ReadAllPackets(reader);
}
[Export] public Vector2 initialPosition = new Vector2();
private NetDataWriter writer;
private NetPacketProcessor packetProcessor;
private Dictionary<uint, ServerPlayer> players = new Dictionary<uint, ServerPlayer>();
public override void _Ready() {
writer = new NetDataWriter();
packetProcessor = new NetPacketProcessor();
packetProcessor.RegisterNestedType((w, v) => w.Put(v), reader => reader.GetVector2());
packetProcessor.RegisterNestedType<PlayerState>();
packetProcessor.SubscribeReusable<JoinPacket, NetPeer>(OnJoinReceived);
// ...
}
public void SendPacket<T>(T packet, NetPeer peer, DeliveryMethod deliveryMethod) where T : class, new() {
if (peer != null) {
writer.Reset();
packetProcessor.Write(writer, packet);
peer.Send(writer, deliveryMethod);
}
}
public void OnJoinReceived(JoinPacket packet, NetPeer peer) {
GD.Print($"Received join from {packet.username} (pid: {(uint)peer.Id})");
ServerPlayer newPlayer = (players[(uint)peer.Id] = new ServerPlayer {
peer = peer,
state = new PlayerState {
pid = (uint)peer.Id,
position = initialPosition,
},
username = packet.username,
});
SendPacket(new JoinAcceptPacket { state = newPlayer.state }, peer, DeliveryMethod.ReliableOrdered);
}
public void OnNetworkReceive(NetPeer peer, NetPacketReader reader, DeliveryMethod deliveryMethod) {
packetProcessor.ReadAllPackets(reader, peer);
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) {
if (peer.Tag != null) {
players.Remove((uint)peer.Id);
}
}
Things to note:
-
packetProcessor.SubscribeReusable
will reuse the same packet class instance instead of creating new ones, so make sure to not store references to it or its contents! This is another advantage of makingPlayerState
a struct, so that we can easily copy it out. - If you had previously defined a struct with
INetSerializable
, you must register it usingpacketProcessor.RegisterNestedType<YourType>
. - LiteNetLib has a couple different packet delivery methods. You'll want to use
DeliveryMethod.ReliableOrdered
(the safest one) for most things, andDeliveryMethod.Unreliable
for fast updates, where a dropped packet or two wouldn't matter too much. - You can pass anything you want as the second argument to
packetProcessor.ReadAllPackets
to be used by the packet callbacks. In this case we only need the peer.
If you test the game now, everything should work fine. The client will send a JoinPacket
and the server will respond with an appropriate JoinAcceptPacket
.
Updating Players
In order to send player info over the network, we first need a player class. You should already have one in your game.
public class Player : Node2D {
[Export] public float moveSpeed = 200;
public static Player instance;
public override void _Ready() {
instance = this;
}
public override void _Process(float delta) {
Vector2 velocity = new Vector2();
if (Input.IsActionPressed("ui_left")) velocity.x -= 1;
if (Input.IsActionPressed("ui_right")) velocity.x += 1;
if (Input.IsActionPressed("ui_up")) velocity.y -= 1;
if (Input.IsActionPressed("ui_down")) velocity.y += 1;
Position += velocity * moveSpeed * delta;
}
}
For any nontrivial game, you'll most likely also need a separate class to represent other players, since they don't need all the processing logic of the client player:
public class RemotePlayer : Node2D {}
Now let's define some more packets:
public class PlayerSendUpdatePacket {
public Vector2 position { get; set; }
}
public class PlayerReceiveUpdatePacket {
public PlayerState[] states { get; set; }
}
public class PlayerJoinedGamePacket {
public ClientPlayer player { get; set; }
}
public class PlayerLeftGamePacket {
public uint pid { get; set; }
}
// ...
public struct ClientPlayer : INetSerializable {
public PlayerState state;
public string username;
public void Serialize(NetDataWriter writer) {
state.Serialize(writer);
writer.Put(username);
}
public void Deserialize(NetDataReader reader) {
state.Deserialize(reader);
username = reader.GetString();
}
}
Since we will be sending ClientPlayer
over the network, we turn it into a struct and implement INetSerializable
.
Now, to send those packets:
public override void Connect() {
// ...
packetProcessor.RegisterNestedType<ClientPlayer>();
packetProcessor.SubscribeReusable<PlayerReceiveUpdatePacket>(OnReceiveUpdate);
packetProcessor.SubscribeReusable<PlayerJoinedGamePacket>(OnPlayerJoin);
packetProcessor.SubscribeReusable<PlayerLeftGamePacket>(OnPlayerLeave);
// ...
}
public override void _Process(float delta) {
if (client != null) {
client.PollEvents();
if (Player.instance != null) {
SendPacket(new PlayerSendUpdatePacket { position = Player.instance.Position }, DeliveryMethod.Unreliable);
}
}
}
public void OnJoinAccept(JoinAcceptPacket packet) {
// ...
Player.instance.Position = player.state.position;
}
public void OnReceiveUpdate(PlayerReceiveUpdatePacket packet) {
foreach (PlayerState state in packet.states) {
if (state.pid == player.state.pid) {
continue;
}
((RemotePlayer)Player.instance.GetParent().GetNode(state.pid.ToString())).Position = state.position;
}
}
public void OnPlayerJoin(PlayerJoinedGamePacket packet) {
GD.Print($"Player '{packet.player.username}' (pid: {packet.player.state.pid}) joined the game");
RemotePlayer remote = (RemotePlayer)((PackedScene)GD.Load("res://scenes/RemotePlayer.tscn")).Instance();
remote.Name = packet.player.state.pid.ToString();
remote.Position = packet.player.state.position;
Player.instance.GetParent().AddChild(remote);
}
public void OnPlayerLeave(PlayerLeftGamePacket packet) {
GD.Print($"Player (pid: {packet.pid}) left the game");
((RemotePlayer)Player.instance.GetParent().GetNode(packet.pid.ToString())).QueueFree();
}
public override void _Ready() {
// ...
packetProcessor.RegisterNestedType<ClientPlayer>();
packetProcessor.SubscribeReusable<PlayerSendUpdatePacket, NetPeer>(OnPlayerUpdate);
// ...
}
public override void _Process(float delta) {
// ...
PlayerState[] states = players.Values.Select(p => p.state).ToArray();
foreach (ServerPlayer player in players.Values) {
SendPacket(new PlayerReceiveUpdatePacket { states = states }, player.peer, DeliveryMethod.Unreliable);
}
}
public void OnJoinReceived(JoinPacket packet, NetPeer peer) {
// ...
foreach (ServerPlayer player in players.Values) {
if (player.state.pid != newPlayer.state.pid) {
SendPacket(new PlayerJoinedGamePacket {
player = new ClientPlayer {
username = newPlayer.username,
state = newPlayer.state,
},
}, player.peer, DeliveryMethod.ReliableOrdered);
SendPacket(new PlayerJoinedGamePacket {
player = new ClientPlayer {
username = player.username,
state = player.state,
},
}, newPlayer.peer, DeliveryMethod.ReliableOrdered);
}
}
}
public void OnPlayerUpdate(PlayerSendUpdatePacket packet, NetPeer peer) {
players[(uint)peer.Id].state.position = packet.position;
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) {
GD.Print($"Player (pid: {(uint)peer.Id}) left the game");
if (peer.Tag != null) {
ServerPlayer playerLeft;
if (players.TryGetValue(((uint)peer.Id), out playerLeft)) {
foreach (ServerPlayer player in players.Values) {
if (player.state.pid != playerLeft.state.pid) {
SendPacket(new PlayerLeftGamePacket { pid = playerLeft.state.pid }, player.peer, DeliveryMethod.ReliableOrdered);
}
}
players.Remove((uint)peer.Id);
}
}
}
Things to note:
- You should probably put the code for sending updates from server on a timer rather than doing it every frame. 20 to 30 updates per second is good enough for most cases. This doesn't matter as much in the client, as it can generally send updates faster than the server.
- It might be useful to verify some of the data received from the server. For example, if it gives the client an invalid pid, Godot will try to access a node that doesn't exist, crashing the game. Those checks have been excluded from the example code for simplicity.
If you run the game now, you should be able to see other connected players moving with you.
Moving Forward
That's about everything you need to start developing a multiplayer game with LiteNetLib. This guide doesn't cover more advanced topics such as creating an authoritative server to prevent cheating or client-side prediction, but those can be implemented on top of the base provided by the code here.
An example implementation of more complex features can be found in NetGameExample, which I would also encourage you to read through, as it was one of the resources I used to learn LiteNetLib and write all of this.
The source code for LiteNetLib has doc comments for most functions, so it could be a good idea to take a look at least. There are many functions and features of the library which weren't covered here.
Hopefully this article has been useful to you. Thanks for reading!
Top comments (1)
Thank you for the tutorial, it helps a lot.
I made it working for Unity + dotnet server but one thing was blocking, peer.Tag, and i dont know exactly why :
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) {
if (peer.Tag != null) // always null, so removed to make it work