Skip to content

API Packets

Macka edited this page Nov 9, 2025 · 2 revisions

The Basics

Packets are used to send data, object states, events and RPCs across the network. Each packet can be uniquely defined for its purpose, e.g. when a player connects your mod may send a ClientboundMyModSettingsPacket packet from the server to the new player. Upon receiving the packet, your mod on the client side will validate and adopt the settings.

Servers and clients register for callbacks for each type of packet they expect to receive. All expected packets must have a listener registered.

Caution

If an unregistered packet type is received a de-sync can occur.

The server can:

  • Send a packet to an individual client
  • Broadcast a packet to all clients

Note

Broadcast packets will be sent to/received by the local client, unless the excludeSelf parameter is set, or the local client's player is specified in the excludePlayer parameter.

Clients can:

  • Send a packet to the server

Packets are defined in either a class or a struct and are made up of zero or more public properties.

The properties can be defined as primitive types (and arrays thereof), or as complex types e.g. classes and structs.

Warning

Packets are reused by the system. If a received packet and its data are not going to be consumed immediately (e.g. passed to a coroutine), clone the packet or copy the data prior to returning from the callback.

Packet Naming Conventions

To maintain clarity and consistency, it's recommended to follow these naming conventions for your packets:

  1. Prefix the packet class name with the direction
    • ClientBound - Packets sent from the server to one or more clients (e.g. ClientBoundMyModSettingsPacket)
    • ServerBound - Packets sent from a client to the server (e.g. ServerBoundMyModActionPacket)
    • Common - Packets that can be sent in either direction (e.g. CommonMyModWidgetInteractionPacket)
  2. Include an identifier for your mod, e.g. PJ for 'Passenger Jobs' mod.
  3. Describe the packet's data WidgetCreation
  4. Suffix all packet class names with Packet for easy identification.

Defining Packets

The Multiplayer API provides two interface classes for defining your custom packets, IPacket for simple packets using primitive data types (or arrays of primitives) and ISerializablePacket for complex packets.

Packets inheriting from IPacket will automatically be serialised and deserialised, while packets inheriting from ISerializablePacket require you to provide your own serialisation and deserialisation routines.

IPacket

To define a simple packet, inherit from the IPacket class and add public properties to represent all of the variables that need to be exchanged.

Acceptable data types:

  • Primitives: bool, byte, sbyte, short, ushort, int, uint, long, ulong, float, double, string, char
  • Built-in classes: IPEndPoint, Guid
  • Arrays of primitives and built-in classes
  • Enums derived from primitives e.g. enum MyEnum : byte
  • UnityEngine classes: Vector2, Vector3, Quaternion

Limitations

Packets based on IPacket can not contain complex data types (e.g. lists, dictionaries, etc.), nor can they contain custom classes or structs.

IPackets do not support null values. If null values need to be included in your packet, use ISerializablePacket instead.

Example packet:

using MPAPI.Interfaces.Packets;

namespace MyMod.Packets;

public class CommonMyModWidgetInteractionPacket : IPacket
{
    public uint WidgetNetId {get; set;}
    public bool WidgetPowered {get; set;}
    public float[] Values {get; set;}
}

Packets can contain helper methods; these will be ignored by the automatic serialiser/deserialiser.

Example packet:

using MPAPI.Interfaces.Packets;

namespace MyMod.Packets;

public class CommonMyModWidgetInteractionPacket : IPacket
{
    public uint WidgetNetId {get; set;}
    public bool WidgetPowered {get; set;}
    public float[] Values {get; set;}

    public static CommonMyModWidgetInteractionPacket From(MyModWidget widget)
    {
        return new()
        {
            WidgetNetId = widget.GetNetId(),
            WidgetPowered = widget.IsPowered,
            Values = widget.GetSimulationValues().ToArray()
        };
    }
}

ISerializablePacket

Packets based on ISerializablePacket are much more flexible than those based on IPacket, however the flexibility comes at a cost; you are required to implement custom serialisation and deserialisation routines. Automatic serialisation is not available.

When writing custom serialisers and deserialisers the process should be treated as writing and reading from a Queue, with all elements being read in the same order they were written. It is important that the number of bytes written and number of bytes read is always equal.

Caution

It is critical that the serialisation and deserialisation is balanced and completed in the same order, otherwise data corruption and de-syncs are likely to occur.

Example packet:

using MPAPI.Interfaces.Packets;
using MPAPI.Util;
using System.Collections.Generic;
using System.IO;

namespace MyMod.Packets;

public class ClientBoundMyModWidgetSpawnPacket : ISerializablePacket
{
    public ushort WidgetNetId {get; set;}
    public bool WidgetPowered {get; set;}
    public Vector3? Position {get; set;}
    public Quaternion? Rotation {get; set;}
    public Dictionary<uint, bool>? States {get; set;}

    void ISerializablePacket.Serialize(BinaryWriter writer)
    {
        // Serialise NetId
        writer.Write(WidgetNetId);

        // Serialise Powered state
        writer.Write(WidgetPowered);


        // Serialise state of Position - write 'true' if a value exists, write 'false' if Position is null 
        writer.Write(Position != null);

        // Use API utility to write the Position value, if it exists
        if (Position != null)
            writer.WriteVector3((Vector3)Position);


        // Serialise state of Rotation - write 'true' if a value exists, write 'false' if Rotation is null 
        writer.Write(Rotation != null);

        // Use API utility to write the Rotation value, if it exists
        if (Rotation != null)
            writer.WriteQuaternion((Quaternion)Rotation);


        // Serialise States
        writer.Write(States?.Count ?? 0);
        if (States != null)
            foreach (var kvp in States)
            {
                writer.Write(kvp.Key);
                writer.Write(kvp.Value);
            }
    }

    void ISerializablePacket.Deserialize(BinaryReader reader)
    {
        // Deserialise NetId
        WidgetNetId = reader.ReadUInt16();

        // Deserialise Powered state
        WidgetPowered = reader.ReadBoolean();

        // Check if Position is available
        if (reader.ReadBoolean())
            Position = reader.ReadVector3();

        // Check if Rotation is available
        if (reader.ReadBoolean())
            Rotation = reader.ReadQuaternion();


        // Deserialise States
        var statesCount = reader.ReadInt32();
        if (statesCount > 0)
        {
            States = new Dictionary<uint, bool>(statesCount);

            for (var i = 0; i < statesCount; i++)
            {
                var key = reader.ReadUInt32();
                var value = reader.ReadBoolean();
                
                States[key] = value;
            }
        }
    }
}

Registering Packet Listeners

Before you can receive packets, you must register a listener (callback) for each packet type your mod expects to receive. Registration can only occur after the IServer and/or IClient instances have been created.

Important

Packet listeners should be registered during OnServerStart or OnClientStart events to ensure they are ready before any packets are received. Failing to register a listener for an expected packet type can cause de-syncs.

Packets implementing IPacket are registered using RegisterPacket(), while packets implementing ISerializablePacket are registered using RegisterSerializablePacket().

The callback signature is different between server and client callbacks due to the server needing to know the packet source.

Server-side Registration

Example of registering an IPacket callback on the server:

internal class MyModServer : MonoBehaviour
{
    IServer server;
    const float MAX_WIDGET_DIST_SQR = 10 * 10;

    protected void Awake()
    {
        // Retrieve the current IServer instance
        server = MultiplayerAPI.Server;

        // Subscribe to network packets
        server.RegisterPacket<CommonMyModWidgetInteractionPacket>(OnWidgetInteractionPacket);
    }

    private void OnWidgetInteractionPacket(CommonMyModWidgetInteractionPacket packet, IPlayer player)
    {
        // Validate the packet
        if (packet.WidgetNetId == 0)
        {
            Debug.Log($"Received {packet.GetType()}, but WidgetNetId is 0");
            return;
        }

        if (!MyModWidget.TryGetFromNetId(packet.WidgetNetId, out MyModWidget widget))
        {
            Debug.Log($"Received {packet.GetType()}, but Widget with NetId {packet.WidgetNetId} was not found");
            return;
        }

        if (player.Position - widget.transform.position).sqrMagnitude > MAX_WIDGET_DIST_SQR){
            Debug.Log($"Received {packet.GetType()}, but player {player.Username} is too far from {widget.Name}");
            return;
        }

        widget.SetPowered(packet.WidgetPowered);
        widget.UpdateSimValues(packet.Values);
    }
}

Client-side Registration

Example of registering a IPacket and ISerializablePacket callbacks on the client:

internal class MyModClient : MonoBehaviour
{
    IClient client;

    protected void Awake()
    {
        // Retrieve the current IClient instance
        client = MultiplayerAPI.Client;

        // Subscribe to network packets
        client.RegisterPacket<CommonMyModWidgetInteractionPacket>(OnWidgetInteractionPacket);
        client.RegisterSerializablePacket<ClientBoundMyModWidgetSpawnPacket>(WidgetSpawnPacket);
    }

    private void OnWidgetInteractionPacket(CommonMyModWidgetInteractionPacket packet)
    {
        // Validate the packet
        if (packet.WidgetNetId == 0)
        {
            Debug.Log($"Client received {packet.GetType()}, but WidgetNetId is 0");
            return;
        }

        if (!MyModWidget.TryGetFromNetId(packet.WidgetNetId, out MyModWidget widget))
        {
            Debug.Log($"Client received {packet.GetType()}, but Widget with NetId {packet.WidgetNetId} was not found");
            return;
        }

        // Update widget state
        widget.SetPowered(packet.WidgetPowered);
        widget.UpdateSimValues(packet.Values);
    }

    private void OnWidgetSpawnPacket(ClientBoundMyModWidgetSpawnPacket packet)
    {
        Debug.Log($"Client received {packet.GetType()}");

        // Spawn new widget
        MyModWidget.Instantiate(packet);
    }

}

Sending Packets

From Server to Client(s)

namespace MyMod.Widget;

public class MyWidget
{
    protected void Awake()
    {
        if (MultiplayerAPI.Instance.IsHost)
        {
            MyMod.Server.SendWidgetSpawnPacket(this);
        }
    } 
}
internal class MyModServer : MonoBehaviour
{
    IServer server;

    // ... other methods ...

    public void SendWidgetSpawnPacket(MyWidget widget)
    {
        // Generate a packet
        var packet = ClientBoundMyModWidgetSpawnPacket.From(widget);

        // Send the packet to all players, except the local client
        server.SendSerializablePacketToAll(packet, excludeSelf: true);
    }
}

From Client to Server

namespace MyMod.Widget;

public class MyWidget
{
    private OnPlayerInteraction(bool powered, float[] values)
    {
        MyMod.Client?.SendWidgetInteractionPacket(netId, powered, values);
    }
}
internal class MyModClient : MonoBehaviour
{
    IClient client;

    // ... other methods ...

    public void SendWidgetInteractionPacket(ushort netId, bool powered, float[] values)
    {
        // Generate a packet
        CommonMyModWidgetInteractionPacket packet = new()
        {
            WidgetNetId = netId,
            WidgetPowered 
        };

        // Send the packet to server
        server.SendPacketToServer(packet);
    }
}

Packet Size Considerations

While the Multiplayer API doesn't impose strict packet size limits, it's important to keep packets reasonably sized for optimal network performance.

Multiplayer will automatically compress ISerializablePackets when the size exceeds 1024 bytes.

Consider:

  • Using a netId system comprised of ushorts to identify objects, rather than strings

    For objects already sync'd by Multiplayer use MultiplayerAPI.Instance.TryGetNetId() and MultiplayerAPI.Instance.TryGetObjectFromNetId() to retrieve the netId and corresponding object respectively.

  • Avoid sending strings

    Where possible use enums to represent string constants; this can also help with multi-language support

Installing
Hosting
Joining a Game
Mod Compatibility

API Overview
Getting Started
Guides
API Reference

Clone this wiki locally