-
Notifications
You must be signed in to change notification settings - Fork 10
Multiplayer
The 2D Top Down genre includes a client-authoritative multiplayer setup, demonstrating how player positions update on each other's screens. This netcode is the culmination of numerous iterations on multiplayer projects. I've lost count of how many times I've done this.
Note
Each packet comes with a small overhead—either 1 or 2 bytes, depending on reliability configured—and a one-byte opcode to identify its purpose. Everything else in the packet is strictly the data we need to send.
Multiplayer.Preview.mp4
Below is an example of a client packet. The client uses this packet to inform the server of its position. The Handle(...) method is executed on the server thread, so only elements accessible on that thread should be accessed.
public class CPacketPosition : ClientPacket
{
[NetSend(1)]
public Vector2 Position { get; set; }
public override void Handle(ENetServer s, Peer client)
{
// The packet handled server-side (ENet Server thread)
}
}Below is an example of a server packet. The server uses this packet to inform each client about the position updates of all other clients. The Handle(...) method is executed on the client thread, so only elements accessible on that thread should be accessed.
public class SPacketPlayerPositions : ServerPacket
{
[NetSend(1)]
public Dictionary<uint, Vector2> Positions { get; set; }
public override void Handle(ENetClient client)
{
// The packet handled client-side (Godot thread)
}
}This client packet sends the username then the position in this order.
public class CPacketJoin : ClientPacket
{
[NetSend(1)]
public string Username { get; set; }
[NetSend(2)]
public Vector2 Position { get; set; }
public override void Handle(ENetClient client)
{
// The packet handled client-side (Godot thread)
}
}public class SPacketPlayerJoinLeave : ServerPacket
{
public uint Id { get; set; }
public string Username { get; set; }
public Vector2 Position { get; set; }
public bool Joined { get; set; }
// Since we need to use if conditions we actually have to type out the Write and Read functions
public override void Write(PacketWriter writer)
{
writer.Write((uint)Id);
writer.Write((bool)Joined);
if (Joined)
{
writer.Write((string)Username);
writer.Write((Vector2)Position);
}
}
public override void Read(PacketReader reader)
{
Id = reader.ReadUInt();
Joined = reader.ReadBool();
if (Joined)
{
Username = reader.ReadString();
Position = reader.ReadVector2();
}
}
public override void Handle(ENetClient client)
{
// The packet handled client-side (Godot thread)
}
}// Player.cs
Game.Net.Client.Send(new CPacketPosition
{
Position = Position
});// This is a snippet of code I copied from my codebase
// This Send function is surrounded by a loop
// The pair.key is the id of the player
// GetOtherPlayers(pair.key) would get all players except pair.key player
// Passing in Peers[pair.key] would send this information to the pair.key player
Send(new SPacketPlayerPositions
{
Positions = GetOtherPlayers(pair.Key).ToDictionary(x => x.Key, x => x.Value.Position)
}, Peers[pair.Key]);Using the [NetExclude] attribute will exclude properties from being written or read in the network.
public class PlayerData
{
public string Username { get; set; }
public Vector2 Position { get; set; }
[NetExclude]
public Vector2 PrevPosition { get; set; }
}Important
Do not directly access properties or methods across threads unless they are explicity marked as thread safe. Not following thread safety will result in random crashes with no errors logged to the console.
Important
A common oversight is using one data type for writing and another for reading. For example, if you have an integer playerCount and you write it with writer.Write(playerCount), but then read it as a byte with playerCount = reader.ReadByte(), the data will be malformed because playerCount wasn't converted to a byte prior to writing. To avoid this, ensure you cast your data to the correct type before writing, even if it feels redundant.