DEV Community

deagahelio
deagahelio

Posted on

Getting Started with LiteNetLib

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
}
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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 a EventBasedNetListener and pass it to NetManager 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;
    }
Enter fullscreen mode Exit fullscreen mode
    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();
    }
Enter fullscreen mode Exit fullscreen mode

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 and connection.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;
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode
    [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);
        }
    }
Enter fullscreen mode Exit fullscreen mode

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 making PlayerState a struct, so that we can easily copy it out.
  • If you had previously defined a struct with INetSerializable, you must register it using packetProcessor.RegisterNestedType<YourType>.
  • LiteNetLib has a couple different packet delivery methods. You'll want to use DeliveryMethod.ReliableOrdered (the safest one) for most things, and DeliveryMethod.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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
Enter fullscreen mode Exit fullscreen mode
    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);
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

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.

The result

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)

Collapse
 
ductri profile image
Duc-Tri

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