Skip to content

Multiplayer Scripting

Valk edited this page Nov 10, 2022 · 28 revisions

This page is a work in progress

Sending a Packet from the Client to the Server

Create a new packet class in Scripts/Netcode/Packets

namespace Sankari.Netcode; // do not forget to include this

// All client packets should start with 'CPacket'
// Replace 'PlayerData' with whatever name suits the packet
public class CPacketPlayerData : APacketClient // the class must extend from APacketClient
{
    // Use properties to setup data variables here
    public Vector2 Position { get; set; }
    public int Health { get; set; }
    public Dictionary<ushort, byte> RandomData { get; set; } // I could not think of a better name

    public override void Write(PacketWriter writer)
    {
	// The order things are written matters
        writer.Write(Position);
	writer.Write(Health);

	// The length must be written whenever sending any kind of list of data
	writer.Write((ushort)RandomData.Count); // cast to ushort because don't need that many values as a int

	foreach (var element in RandomData)
	{
	    writer.Write((ushort)element.Key); // sometimes it helps to cast for readability
	    writer.Write((byte)element.Value);
	}
    }

    public override void Read(PacketReader reader)
    {
	// The order things are read matters
	// A common mistake I do all the time is forget to assign the reader.ReadValue() to a variable
	// Don't just leave it as 'reader.ReadValue()', do 'var myValue = reader.ReadValue()'
        Position = reader.ReadVector2();
	Health = reader.ReadInt();

	// Read the length from the dictionary
	var length = reader.ReadUShort();

	// Do not forget to initialize the dictionary
	RandomData = new Dictionary<ushort, byte>();

	for (int i = 0; i < length; i++)
	{
	    var key = reader.ReadUShort(); // do not make the mistake of doing reader.ReadShort();
	    var value = reader.ReadByte();

	    // do not use RandomData[key] = value; because this encourages duplicates not being handled
	    // if a duplicate is added with RandomData.Add(key, value); the game will crash but this
	    // is what we want as this should be handled accordingly
	    RandomData.Add(key, value); 
	}
    }

    public override void Handle(ENet.Peer peer)
    {
	// This is handled server-side
	// Do something with the data
	var player = Net.Server.Players[(byte)peer.ID];

        player.Position = Position;
	player.Health = Health;
	player.RandomData = RandomData;
    }
}

Send the client packet to the server

// The following code is thread-safe, it can be executed from anywhere or in this case from the player script
// You will need to create the ClientPacketOpcode.PlayerData enum value in the ClientPacketOpcode enum
// All netcode opcode enums are located in Scripts/Netcode/_Opcodes.cs
Net.Client.Send(ClientPacketOpcode.PlayerData, new CPacketPlayerPosition
{
    Position = PlayerPosition,
    Health = PlayerHealth,
    RandomData = PlayerRandomData
});

Sending a Packet from the Server to the Client

Sending a packet from the server to a client is much like sending a packet from a client to the server but there are a few differences.

  • Instead of CPacket it is SPacket
  • Instead of APacketClient it is APacketServer
  • Instead of void Handle() it is async Task Handle()
  • Instead of Handle() being executed server-side, it is executed client-side

Lets create the packet

// Sometimes it is helpful to create a secondary opcode so we are not clogging up the main opcode pipeline
// Since the first opcode 'ServerPacketOpcode' is sent as a byte, there can not be more than 255 server opcodes
public enum ServerGameInfo
{
    PlayerJoinLeave,
    PlayersOnServer,
    StartLevel,
    MapPosition,
    PeerId
}

public class SPacketGameInfo : APacketServer
{
    public ServerGameInfo ServerGameInfo { get; set; }

    // Comments are used to organize properties as this packet is a multi-purpose packet
    // Only one ServerGameInfo value can be specified at a time

    // PlayerJoinLeave
    public string Username { get; set; }
    public bool Joining { get; set; }
    public byte Id { get; set; }

    // PlayersOnServer
    public Dictionary<byte, string> Usernames { get; set; }

    // StartLevel
    public string LevelName { get; set; }

    // MapPosition
    public Vector2 MapPosition { get; set; }

    // PeerId
    public byte PeerId { get; set; }

    public override void Write(PacketWriter writer)
    {
        writer.Write((ushort)ServerGameInfo);

        // Handle each opcode accordingly 
        switch (ServerGameInfo)
        {
            case ServerGameInfo.PlayerJoinLeave:
                writer.Write(Username);
                writer.Write(Joining);
                writer.Write((byte)Id);
                break;
            case ServerGameInfo.PlayersOnServer:
                writer.Write((byte)Usernames.Count);
                foreach (var player in Usernames) 
                {
                    writer.Write((byte)player.Key);
                    writer.Write(player.Value);
                }
                break;
            case ServerGameInfo.StartLevel:
                writer.Write(LevelName);
                break;
            case ServerGameInfo.MapPosition:
                writer.Write(MapPosition);
                break;
            case ServerGameInfo.PeerId:
                writer.Write((byte)PeerId);
                break;
        }
    }

    public override void Read(PacketReader reader)
    {
        ServerGameInfo = (ServerGameInfo)reader.ReadUShort();

        // notice Logger.Log(...) is not used directly here because Client.Log gives a nice prefix and color automatically
        Net.Client.Log($"Received: {ServerGameInfo}");

        switch (ServerGameInfo)
        {
            case ServerGameInfo.PlayerJoinLeave:
                Username = reader.ReadString();
                Joining = reader.ReadBool();
                Id = reader.ReadByte();
                break;
            case ServerGameInfo.PlayersOnServer:
                var length = reader.ReadByte();
                Usernames = new Dictionary<byte, string>();
                for (int i = 0; i < length; i++) 
                {
                    var key = reader.ReadByte();
                    var value = reader.ReadString();

                    Usernames.Add(key, value);
                }
                break;
            case ServerGameInfo.StartLevel:
                LevelName = reader.ReadString();
                break;
            case ServerGameInfo.MapPosition:
                MapPosition = reader.ReadVector2();
                break;
            case ServerGameInfo.PeerId:
                PeerId = reader.ReadByte();
                break;
        }
    }

    public override async Task Handle()
    {
        // if there are no uses of await keyword in the Handle() method then add 'await Task.FromResult(0);' at the very bottom
        switch (ServerGameInfo)
        {
            case ServerGameInfo.PlayerJoinLeave:
                HandlePlayerJoinLeave();
                break;
            case ServerGameInfo.PlayersOnServer:
                HandlePlayersOnServer();
                break;
            case ServerGameInfo.StartLevel:
                await HandleStartLevel();
                break;
            case ServerGameInfo.MapPosition:
                HandleMapPosition();
                break;
            case ServerGameInfo.PeerId:
                HandlePeerId();
                break;
        }
    }

    // ...
}

Send the server packet to all clients but the host

Net.Server.SendToEveryoneButHost(ServerPacketOpcode.GameInfo, new SPacketGameInfo
{
        ServerGameInfo = ServerGameInfo.StartLevel,
	LevelName = LevelManager.CurrentLevel
});

Send the server packet to a specific client specified by peer

server.Send(ServerPacketOpcode.GameInfo, new SPacketGameInfo
{
    ServerGameInfo = ServerGameInfo.PlayersOnServer,
    Usernames = server.Players.ToDictionary(x => x.Key, x => x.Value.Username)
}, peer);

Send the server packet to everyone but a specific client specified by peer ID

SendToOtherPlayers(netEvent.Peer.ID, ServerPacketOpcode.GameInfo, new SPacketGameInfo
{
    ServerGameInfo = ServerGameInfo.PlayerJoinLeave,
    Username = username,
    Joining = false,
    Id = (byte)netEvent.Peer.ID
});

AutoHostJoin Property

Set the linker settings to the following for debugging multiplayer.

image

image

The Auto Host Join option makes it so if the game is launched through the editor a server will be setup on local port and host client will auto connect to this server. If the game is launched through an exported release, a client will auto attempt to join this local host server. The game window titles are updated to make this more clear. If you do not check Auto Host Join, you will have to go to the map screen and press Esc to open up the "UI Map Menu" which gives you all the buttons needed to host or join a server.

Thread Safety

This game makes use of 3 threads (Godot, Server, Client). Do not directly access public variables or methods from these threads to other threads. If you want to communicate between threads please make use of the appropriate ConcurrentQueue<T> channels. Violating thread safety can lead to frequent random game crashes with usually no errors in console making these types of issues extremely hard to track down when they start acting up.

This issue will stay up until either the wiki is updated with this information or the multiplayer code becomes insanely easy to use it does not even need a guide

Ignoring Opcode Log Messages

Specific opcode messages can be ignored if you don't want to be spammed when debugging a specific part of the netcode.

In the future a checkbox option will be added to completely turn opcode logging off.

image

Clone this wiki locally