Skip to content

API Getting Started

Macka edited this page Nov 29, 2025 · 5 revisions

The Basics

Before using the Multiplayer Mod API it is important to understand some basic concepts Multiplayer Mod uses and the recommended design patterns for working with Multiplayer Mod.

Multiplayer Architecture

Multiplayer mod is split into two parts, 'host'/'server' and 'client'. The host's role is to coordinate the game, keeping all players in sync and to act as a central repository for game saves, including storing the save data of all players. The client's role is to represent a single player, receiving state information from the server and setting the player's game accordingly. The client is also required to notify the the host of any actions a player has taken or wishes to take, e.g. starting a locomotive, completing a job, unloading cargo, etc.

For a multiplayer session to work, there must be at least one host and zero or more clients. For a standard/self-hosted game, the host will run both a host instance and a client instance. For a dedicated server (not yet implemented), only a host instance will be present.

Transport Layer

Steam's Networking Sockets are used to allow clients and the host to communicate and exchange messages. All clients connect to the host via these sockets, including the 'local client' on a self-hosted game. Clients do not communicate with each other directly, instead, all messages between clients are passed through the host.

Network Messages / Packets

Packets are used to send data, object states, events and RPCs across the network. Each packet is uniquely defined and named for its purpose. For example, when a TrainCar is spawned on the host, a ClientboundSpawnTrainCarPacket is sent to all clients; the packet includes information such as the TrainCar's type, brake state, paint theme, coupling data, etc.

Packets received by clients and the server are validated and either rejected or accepted and processed. The server in some instances relays/forwards the packet to other clients.

Network IDs

To synchronise objects across players/clients, each object to be synchronised is given a network ID (NetId). NetIds are unique for the object type, but are not globally unique, i.e. a TrainCar, Job and RailTrack can all have a NetId of '1'; this means the NetId alone is insufficient for identifying the object.

NetIds are used in the packets between host and clients to identify the object to be created, updated, or destroyed.

Most NetIds are ushort types, however special cases do exist where a uint is used; the list of objects and their NetId types can be found XXXX

Server-authoritative Model

Most synchronised objects have been designed to be 'server-authoritative'. It is recommended that your mod follows the same design pattern.

Host

  • Assignings NetIds to synchronised objects when they are created
    e.g. TrainCar, ItemBase, Job, Task, Player, etc.
  • Sends creation and deletion events to clients
  • Sends state updates and RPCs to clients
  • Receives requests from clients to perform an action
    e.g. rerail a TrainCar, take/complete a Job

Clients

  • Instantiate objects as directed by the server, assigning the NetId the server has provided.

  • Should not instantiate objects, unless instructed to do so by the server. i.e. a client should not spawn a TrainCar unless the server has instructed it to do so.

  • Should send a request to the server to perform an action (e.g. spawning a car, rerailing a carriage, loading/unloading cargo, etc.); the server should validate the action, and if appropriate, carry it out.

    Where local feedback (e.g. sound, rerailment, etc.) is expected, the client should await a response (with suitable time-out) to determine if the action was successful before providing appropriate feedback

Network/Sim Ticks

MonoBehaviours call Update() as fast as the player's frame rate will allow, i.e. with a frame rate of 60fps, Update() will be called 60 times per second. This causes two issues for Multiplayer, the first being each client will likely have a different frame rate, the second being high frame rates will result in many network updates and may flood connections with unnecessary traffic.

Multiplayer mod implements a tick system to help keep everything in sync, regardless of each player's frame rate.

The client and the server generate 24 network ticks per second, with a tick occuring on average every 41.6 ms.

Various components of the Multiplayer mod subscribe to the OnTick event and use this to dispatch updates to clients or the server.

Project Setup

Setting up your project to consume the API requires several key steps:

  1. Choose your implementation method
  2. Setup references and dependencies
  3. Register your mod's compatibility
  4. Setup callbacks for ServerStarted, ClientStarted, ServerStopped and ClientStopped events
  5. Define your mod's custom packets (if required)
  6. Setup callbacks for auxiliary events, e.g. player connection, packet arrival, sim ticks, etc.

Choose Your Implementation Method

Choose the appropriate method for loading and referencing the Multiplayer Mod API based on your mod's requirements. The below decision matrix can be used to select the appropriate referencing pattern.

Warning

Unless you ensure Multiplayer Mod is loaded prior to your mod, MultiplayerAPI.dll should not be packaged or bundled with your mod.

Packaging the DLL without ensuring Multiplayer is loaded first may result in an old version being loaded, which will prevent Multiplayer Mod from loading.

Key

  • MP Required:
    • No if your mod works standalone
    • Yes if your mod must have Multiplayer present
  • Complex Integration:
    • No if your mod only needs to access minimal API features and does not use any interfaces (e.g. IPlayer, IServer, etc.)
    • Yes if your mod requires interfaces
  • Early Patching:
    • No if your mod can apply patches after Multiplayer
    • Yes if your mod needs to apply patches before Multiplayer

Decision matrix

MP Required? Complex Integration? Early Patching? Implementation Method Load Configuration
No No No Shim Use LoadAfter
No No Yes Shim Use ModEntry.LateUpdate
No Yes No Shim with Integration Use LoadAfter
No Yes Yes Shim with Integration Use ModEntry.LateUpdate
Yes No/Yes No Hard Use LoadAfter + Requirements
Yes No Yes Shim Use ModEntry.LateUpdate + Requirements
Yes Yes Yes Shim with Integration Use ModEntry.LateUpdate + Requirements

Implementation Methods

Hard Dependency

Use when:

  • Multiplayer Mod is always required
  • No early patching needed
  • Simple or complex integration needs

Pros: Direct API access, type safety, IntelliSense support
Cons: Hard dependency on Multiplayer Mod

Setup:

  1. Add API Reference:

    • Via NuGet: Install the Multiplayer Mod API package
      Or
    • Manually: Reference MultiplayerAPI.dll from Mods\Multiplayer\ folder
  2. Update info.json:

    {
        "Id": "MyMod",
        "Requirements": ["Multiplayer"],
        "LoadAfter": ["Multiplayer"]
    }
  3. Reference namespaces as required:

    using MPAPI;
    using MPAPI.Types;
    using MPAPI.Interfaces;
    using MPAPI.Util;

Shim Without Integration

Use when:

  • Multiplayer Mod is optional
  • Simple API usage (few methods needed, no interfaces used)
  • Need to avoid hard dependency

Pros: No hard dependency, graceful fallback
Cons: No type safety, reflection overhead, more verbose

Important

Don't add references to MultiplayerAPI.dll, doing so will create a hard dependency.

Setup:

  1. Create a shim class:
    Example shim exposing IMultiplayerAPI.MultiplayerVersion and IMultiplayerAPI.IsHost properties.

     using System;
     using System.Linq;
     using System.Reflection;
       
     public static class MultiplayerShim
     {
         const string MULTIPLAYER_MOD_ID = "Multiplayer";
         const string MPAPI_ASSEMBLY_NAME = "MultiplayerAPI";
         const string MPAPI_TYPE_NAME = "MPAPI.MultiplayerAPI";
         const string MPAPI_INSTANCE_PROPERTY = "Instance";
    
         const string MP_VERSION_PROPERTY = "MultiplayerVersion";
         const string IS_HOST_PROPERTY = "IsHost";
    
         private static object? _mpApiInstance;
         private static PropertyInfo? _mpVersion;
         private static PropertyInfo? _isHost;
         
         internal static bool IsInitialized { get; private set; } = false;
    
         internal static string MultiplayerVersion
         {
           get
           {
             if (_mpVersion == null)
               return string.Empty;
    
             return (string)_mp_Version.GetValue(_mpApiInstance)!;
           }
         }
    
         internal static bool IsHost
         {
           get
           {
             if (_isHost == null)
               return true;
    
             return (bool)_isHost.GetValue(_mpApiInstance)!;
           }
         }
         
         // Add more wrapped methods/properties as needed...
    
         internal static void Initialize(UnityModManager.ModEntry modEntry)
         {
           UnityModManager.ModEntry? multiplayer = UnityModManager.FindMod(MULTIPLAYER_MOD_ID);
           var mpapiAssembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == MPAPI_ASSEMBLY_NAME);
    
           try
           {
               if (multiplayer?.Enabled == true && mpapiAssembly != null)
               {
                   if (!File.Exists(path))
                   {
                       return;
                   }
    
                   var mpApiType = mpapiAssembly.GetType(MPAPI_TYPE_NAME);
                   var instanceProp = mpApiType?.GetProperty(MPAPI_INSTANCE_PROPERTY, BindingFlags.Public | BindingFlags.Static);
    
                   _mpApiInstance = instanceProp!.GetValue(null);
    
                   // Find properties and methods by reflection
                   _isHost = _mpApiInstance.GetType().GetProperty(IS_HOST_PROPERTY, BindingFlags.Public | BindingFlags.Instance);
    
                   IsInitialized = _mpApiInstance != null && _isHost != null;
               }
    
               modEntry.Logger.Log("Multiplayer API Loaded.");
           }
           catch (Exception ex)
           {
               modEntry.Logger.Warning($"Failed to load multiplayer API.\r\n{ex.Message}\r\n{ex.StackTrace}");
           }
         }
     }
  2. Update info.json:
    If your mod can apply patches after multiplayer, add a LoadAfter to your Info.json, otherwise leave it out.

    {
        "Id": "MyMod",
        "LoadAfter": ["Multiplayer"]
    }

    [!Note] UMM does not currently have a LoadBefore parameter. Mods are loaded alphabetically unless a Requirements or LoadAfter has been specified.

  3. Initialise the shim:
    a. Early Patching
    Call your shim's Initialize() method when your mod loads

    b. Late Patching
    Setup a listener for UnityModManager.ModEntry.LateUpdate()

    public static void Load(UnityModManager.ModEntry modEntry)
    {
        // Your mod's setup code here
    
        // Set up a listener for LateUpdate()
        modEntry.LateUpdate += InitializeShim;
    }
    
    public static void InitializeShim()
    {
       // Remove listener for LateUpdate()
       modEntry.LateUpdate -= InitializeShim;
    
       // Initialise shim
       MultiplayerShim.Initialize();
    }

Shim With Integration

Use when:

  • Multiplayer Mod is optional
  • Complex API usage (access to all methods and interfaces required)
  • Need to avoid hard dependency

Pros: Direct API access, type safety, IntelliSense support
Cons: Requires careful planning of interactions between core mod and integration

Important

Don't add references to MultiplayerAPI.dll in your main mod project, doing so will create a hard dependency.

Setup:

  1. Add an integration project to your solution:
    Solution Structure:

    MyMod.sln
    ├── MyMod.csproj                    (main mod)
    |   └── MultiplayerShim.cs          (used to load the integration)
    └── MyMod.Multiplayer.csproj        (integrations between API and core mod)
        └── Bootstrap.cs                (called to initialise the integration)
    
  2. Add API Reference to your integration project:

  3. Create Bootstrap class in your integration project: This will do all of the required setup of listeners, etc.

    using MPAPI;
    using MPAPI.Types;
    using MPAPI.Interfaces;
    using MPAPI.Util;
    using YourMod;
    public static class Bootstrap
    {
        public static void Initialize()
        {
            if (!MultiplayerAPI.IsMultiplayerLoaded)
                return;
    
            // Set compatibility state for your mod
            MultiplayerAPI.Instance.SetModCompatibility(PJMain.ModEntry.Info.Id, MultiplayerCompatibility.All);
    
            // Apply any extra patches required for multiplayer that can't be applied in the your core mod
            Harmony harmony = new("mymod.multiplayer");
            harmony.PatchAll();
    
            // Register custom task type serialisers
    
            // Setup listeners for server and client starting / stopping
        }
    }
  4. Create a shim class in your main project:
    This will be used to load your integration dll. If required, add reflected methods to the Multiplayer API or your integration dll.

     using System;
     using System.Linq;
     using System.Reflection;
       
     public static class MultiplayerShim
     {
         const string MULTIPLAYER_MOD_ID = "Multiplayer";
         const string MPAPI_ASSEMBLY_NAME = "MultiplayerAPI";
    
         const string MP_INTEGRATION_DLL = "MyMod.Multiplayer.dll";
         const string MP_INTEGRATION_BOOTSTRAP = "MyMod.Multiplayer.Bootstrap";
         const string MP_INTEGRATION_INIT_METHOD = "Initialize";
    
         internal static void Initialize(UnityModManager.ModEntry modEntry)
         {
           UnityModManager.ModEntry? multiplayer = UnityModManager.FindMod(MULTIPLAYER_MOD_ID);
           var mpapiAssembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == MPAPI_ASSEMBLY_NAME);
           var path = Path.Combine(modEntry.Path, MP_INTEGRATION_DLL);
    
           try
           {
               if (multiplayer?.Enabled == true && mpapiAssembly != null)
               {
                   if (!File.Exists(path))
                   {
                     modEntry.Logger.Warning($"{MP_INTEGRATION_DLL} was not found, unable to activate multiplayer integration.");
                       return;
                   }
    
                   var mpAssembly = Assembly.LoadFile(path);
                   var bootstrap = mpAssembly.GetType(MP_INTEGRATION_BOOTSTRAP);
    
                   if (bootstrap == null)
                   {
                       modEntry.Logger.Warning($"Failed to find {MP_INTEGRATION_BOOTSTRAP} in {MP_INTEGRATION_DLL}, multiplayer support will be disabled.");
                       return;
                   }
    
                   var init = bootstrap.GetMethod(MP_INTEGRATION_INIT_METHOD, BindingFlags.Public | BindingFlags.Static);
                   init?.Invoke(null, null);
               }
    
               modEntry.Logger.Log("Multiplayer API Loaded.");
           }
           catch (Exception ex)
           {
               modEntry.Logger.Warning($"Failed to load multiplayer API.\r\n{ex.Message}\r\n{ex.StackTrace}");
           }
         }
     }
  5. Update info.json:
    If your mod can apply patches after multiplayer, add a LoadAfter to your Info.json, otherwise leave it out.

    {
        "Id": "MyMod",
        "LoadAfter": ["Multiplayer"]
    }

    [!Note] UMM does not currently have a LoadBefore parameter. Mods are loaded alphabetically unless a Requirements or LoadAfter has been specified.

  6. Initialise the shim:
    a. Early Patching
    Call your shim's Initialize() method when your mod loads

    b. Late Patching
    Setup a listener for UnityModManager.ModEntry.LateUpdate()

    public static void Load(UnityModManager.ModEntry modEntry)
    {
        // Your mod's setup code here
    
        // Set up a listener for LateUpdate()
        modEntry.LateUpdate += InitializeShim;
    }
    
    public static void InitializeShim()
    {
       // Remove listener for LateUpdate()
       modEntry.LateUpdate -= InitializeShim;
    
       // Initialise shim
       MultiplayerShim.Initialize();
    }

Register Your Mod's Compatibility State

Regardless of your chosen implementation method, the next step is to register your mod's compatibility state. Registering the state can be done by json or programmatically by calling MultiplayerAPI.Instance.SetModCompatibility(). See Declaring Compatibility for more information.

Setup Callbacks for Client/Server Start/Stop

The level of functionality you implement and how you implement it will depend on your mod's specific requirements, but in general, your mod will need to register for ther ServerStarted, ClientStarted, ServerStopped and ClientStopped events.

The following example registers an event handler for the server and client start events. The event handlers create a game object for managing server and client concerns and cleanup is handled in the manager's OnDestroy() event.

Note

This example assumes 'MyMod' has a hard dependency on the Multiplayer API and will need to be adjusted depending on the implementation in use.

public static class MyMod
{
    public static UnityModManager.ModEntry ModEntry;

    public static bool Load(UnityModManager.ModEntry modEntry)
    {
        ModEntry = modEntry;
        
        // Your mod initialisation here
        // ...
        if (MultiplayerAPI.IsMultiplayerLoaded)
        {
            MultiplayerAPI.Instance.SetModCompatibility(ModEntry.Info.Id, MultiplayerCompatibility.All);

            MultiplayerAPI.ServerStarted += OnServerStarted;
            MultiplayerAPI.ClientStarted += OnClientStarted;
        }

        return true;
    }

    private static void OnServerStarted(IServer server)
    {
        // How you handle the server starting is up to you
        // In this example we are injecting a server manager into the scene, but you could
        // also just integrate it into your mod's existing workflow.
        // Keep in mind on a non-dedicated server, both the client and server will run concurrently
        GameObject go = new GameObject("MyMod Server", [typeof(MyModServer)]);
        GameObject.DontDestroyOnLoad(go);
    }

    private static void OnClientStarted(IClient client)
    {
        // How you handle the client starting is up to you
        // In this example we are injecting a client manager into the scene, but you could
        // also just integrate it into your mod's existing workflow.
        // Keep in mind on a non-dedicated host, both the client and server will run concurrently
        GameObject go = new GameObject("MyMod Client", [typeof(MyModClient)]);
        GameObject.DontDestroyOnLoad(go);
    }
}

Define Custom Packets

Communicating between the server and clients is done through packets. If your mod needs to communicate states and events not already handled by Multiplayer, you will need to define custom packets.

The following example defines a simple packet for sending mod settings from the server to a client:

using MPAPI.Interfaces.Packets;

namespace MyMod.Packets;

public class CommonMyModWidgetInteractionPacket : IPacket
{
    public uint Parameter1 {get; set;}
    public bool Parameter2 {get; set;}
}

For more information on creating packets see Packets.

Setup Callbacks for Auxiliary Events

Your mod may need to respond to events such as players joining or leaving, custom packet arrival, and network ticks.

Important

Each time a game session starts, a fresh instance of IServer and/or IClient is created (dependening on whether the player's role is host or client). This means you must re-register all event handlers each time the instances are created.

The following examples are a high-level guide only

Example server manager:

internal class MyModServer : MonoBehaviour
{
    IServer server;

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

        // Subscribe to tick events
        MultiplayerAPI.Instance.OnTick += OnTick;

        // Subscribe to player events
        server.OnPlayerConnected += OnPlayerConnected;
        server.OnPlayerDisconnected += OnPlayerDisconnected;

        // Subscribe to network packets
        // Note: only packets that will be received by the server need to be registered here
        server.RegisterPacket<CommonMyModWidgetInteractionPacket>(OnWidgetInteractionPacket);
    }

    protected void OnDestroy()
    {
        // Unsubscribe from tick events
        MultiplayerAPI.Instance.OnTick -= OnTick;

        // Unsubscribe from player events
        server.OnPlayerConnected -= OnPlayerConnected;
        server.OnPlayerDisconnected -= OnPlayerDisconnected;
    }

    private void OnTick(uint tick)
    {
        // Logic for any ticked events here
    }

#region Player Events

    private void OnPlayerConnected(IPlayer player)
    {
        // Note: This event occurs when the player is authenticated and before the player receives game state info
        Debug.Log($"Player {player?.PlayerId} (\"{player?.Username}\") has connected.");

        // setup any caches or player specific data

        // Send mod any settings, parameters, etc. that are important to how the mod behaves
        SendSettings(player);
    }

    private void OnPlayerDisconnected(IPlayer player)
    {
        // Player has disconnected
        // Note: This event occurs immediately prior to destroying the player object
        // Complete all cleanup prior to returning from this method

        Log($"Player \"{player?.Username}\" has disconnected");
    }

#endregion

#region Packet Senders
    private void SendSettings(IPlayer player)
    {
        // Send mod settings, parameters, etc.
        ClientboundMyModSettingsPacket packet = new()
        {
            Parameter1 = MyMod.Settings.Parameter1,
            Parameter2 = MyMod.Settings.Parameter2
        };

        server.SendPacketToPlayer(packet, player);
    }
#endregion

#region Packet Callbacks

    private void OnWidgetInteractionPacket(CommonMyModWidgetInteractionPacket packet, IPlayer player)
    {
       Log($"Received {packet.GetType()} from player: {player.Username}");

       // Validate the data and action as required
    }

#endregion
}

Example client manager:

internal class MyModClient : MonoBehaviour
{
    IClient client;

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

        // Subscribe to game tick events
        MultiplayerAPI.Instance.OnTick += OnTick;

        // Subscribe to player events
        client.OnPlayerConnected += OnPlayerConnected;
        client.OnPlayerDisconnected += OnPlayerDisconnected;

        // Subscribe to network packets
        // Note: only packets that will be received by the client need to be registered here
        client.RegisterPacket<CommonMyModWidgetInteractionPacket>(OnWidgetInteractionPacket);
        client.RegisterSerializablePacket<ClientboundMyModSettingsPacket>(OnSettingsPacket);

        // Check if we are also a host
        // Some mods may need to change behaviour if they are only a client
        if (MultiplayerAPI.Instance.IsHost)
        {
            Debug.Log("We are both host and client");
        }
        else
        {
            Debug.Log("We are only a client game");
        }
    }

    protected void OnDestroy()
    {
        // Unsubscribe from tick events
        MultiplayerAPI.Instance.OnTick -= OnTick;

        // Unsubscribe from player events
        client.OnPlayerConnected -= OnPlayerConnected;
        client.OnPlayerDisconnected -= OnPlayerDisconnected;
    }

    private void OnTick(uint tick)
    {
        // Logic for any ticked events here
    }

#region Player Events

    private void OnPlayerConnected(IPlayer player)
    {
        // This event is called when another player joins the game

        Debug.Log($"Player \"{player?.PlayerId}\" has connected.");
    }

    private void OnPlayerDisconnected(IPlayer player)
    {
        // This event is called when another player disconnects

        Debug.Log($"Player \"{player?.PlayerId}\" has connected.");
    }

#endregion

#region Packet Senders
    // This method intended to be called from custom widget
    public void SendWidgetInteraction(uint netId, InteractionType type)
    {
        // Send mod settings, parameters, etc.
        CommonMyModWidgetInteractionPacket packet = new()
        {
            WidgetId = netId,
            Interaction = type
        };

        client.SendPacketToServer(packet);
    }
#endregion

#region Packet Callbacks

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

       // Validate and apply settings
       // Note: be careful not to permanenetly overwrite the player's settings
       MyMod.Settings.Parameter1 = packet.Parameter1;
       MyMod.Settings.Parameter2 = packet.Parameter2;
    }

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

       // action as required
    }

#endregion
}

Further Reading

This article gives a basic overview of how Multiplayer Mod is structured and how to consume the APIs, for more detailed use cases and examples, please see the topics in the sidebar.

Installing
Hosting
Joining a Game
Mod Compatibility

API Overview
Getting Started
Guides
API Reference

Clone this wiki locally