Skip to content

Plugin Development

Tsu edited this page Jun 10, 2026 · 2 revisions

Plugin Development

Nimbus plugins are .NET class libraries that reference Nimbus.Api.dll. They live in the plugins/ directory next to the proxy executable and are loaded at startup. They can be hot-reloaded without restarting the proxy.

Project setup

Create a class library targeting .NET 10:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <AssemblyName>MyPlugin</AssemblyName>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="Nimbus.Api">
      <HintPath>path/to/Nimbus.Api.dll</HintPath>
    </Reference>
  </ItemGroup>
</Project>

Only reference Nimbus.Api.dll. Do not reference Nimbus.Proxy.dll directly - its types are internal and the API surface can change between versions.

Plugin manifest

Place a <assemblyname>.plugin.json file next to your DLL in the plugins/ directory:

{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "apiVersion": "0.1",
  "dependencies": []
}
Field Description
id Unique plugin identifier. Used in plugins.disabled lists and log output.
name Display name shown by nimctl plugins.
version Plugin version string.
apiVersion Nimbus API version this plugin targets. Current version: 0.1.
dependencies Plugin IDs that must be loaded before this one.

IPlugin

public interface IPlugin
{
    string Name    { get; }
    string Version { get; }
    void Initialize(IProxyApi api);
    void Shutdown();
}
  • Initialize - called once on load, and again after every hot-reload. Subscribe to events here.
  • Shutdown - called before the plugin is unloaded (on proxy shutdown or before a hot-reload). Clean up resources here.

Minimal plugin:

using Nimbus.Proxy;

public sealed class MyPlugin : IPlugin
{
    public string Name    => "my-plugin";
    public string Version => "1.0.0";

    public void Initialize(IProxyApi api)
    {
        api.Events.Subscribe<PlayerConnectEvent>(e =>
        {
            api.LogInfo($"connection from {e.Player.ClientRemote}");
            return Task.CompletedTask;
        });
    }

    public void Shutdown() { }
}

IProxyApi

The object passed to Initialize. Your main entry point to everything in the proxy.

public interface IProxyApi
{
    EventBus Events { get; }

    IEnumerable<IPlayer> Players { get; }
    IPlayer? TryGetPlayer(long sessionId);
    IPlayer? FindPlayerByUid(string uid);
    IPlayer? FindPlayerByName(string name);

    Task<IServerInfo?> ResolveServerAsync(string serverId, CancellationToken ct);

    void LogInfo(string message);
    void LogWarn(string message);
}

ResolveServerAsync checks the registry snapshot first, then falls back to the static [servers] pool. Use it to turn a server ID string into an IServerInfo you can pass to TransferAsync.


IPlayer

public interface IPlayer
{
    long     Id                      { get; }  // session number (increments per connect)
    string?  Uid                     { get; }  // VS player UID, available after Identification
    string?  Name                    { get; }  // player name, available after Identification
    string   ClientRemote            { get; }  // real IP, captured at TCP accept time
    IServerInfo? CurrentServer       { get; }  // currently connected backend
    bool     SupportsSeamlessTransfers { get; } // has Nimbus.Client installed

    Task<string?> TransferAsync(IServerInfo target, string? reason = null);
    Task<string?> TransferAsync(IServerInfo target, string mode, string? reason = null);
    void Disconnect(string? reason = null);
}

TransferAsync returns null on success, or a short failure reason string on error. mode is "redirect" or "seamless".

Uid and Name are null until the proxy has parsed the VS Identification frame from the client, which happens very early in the session (usually within the first few milliseconds). For PlayerConnectEvent, they may still be null - use ClientRemote for IP-based decisions there.


IServerInfo and ServerInfo

public interface IServerInfo
{
    string ServerId { get; }
    string Host     { get; }
    int    Port     { get; }
}

Construct a target directly when you already know the address:

var target = new ServerInfo("hub", "127.0.0.1", 42421);
await player.TransferAsync(target, "redirect", "sending to hub");

Or resolve by ID:

var target = await api.ResolveServerAsync("hub", default);
if (target != null)
    await player.TransferAsync(target);

Events

Subscribe with api.Events.Subscribe<TEvent>(handler). The handler receives the event object and returns a Task. Handlers run sequentially in subscription order. Exceptions are caught and logged - one bad handler cannot crash a session or block others.

PlayerConnectEvent

Fires when a new TCP connection is accepted, before any backend is contacted.

api.Events.Subscribe<PlayerConnectEvent>(e =>
{
    if (IsBanned(e.Player.ClientRemote))
        e.Deny("You are banned from this server.");
    return Task.CompletedTask;
});
Member Description
Player The new session. Uid and Name are null - Identification not parsed yet. Use ClientRemote for IP.
Deny(reason) Forges a VS disconnect packet and closes the connection before it reaches any backend.
IsDenied true if any handler called Deny.

PlayerChooseInitialServerEvent

Fires after the backend pool is built, before any upstream socket opens. Override which backend the player lands on.

api.Events.Subscribe<PlayerChooseInitialServerEvent>(async e =>
{
    if (IsVip(e.Player.Uid))
        e.Target = await api.ResolveServerAsync("vip-world", default);
});
Member Description
Player The session. Uid may be available if the first frame was an Identification packet.
Target Get or set the chosen backend. Set to null to let the default try list decide.
Cancel(reason) Cancel the connect entirely. Player receives a disconnect message.
IsCancelled true if cancelled.

ServerPreConnectEvent

Fires before each upstream TCP connect attempt - on initial connect and before every transfer.

api.Events.Subscribe<ServerPreConnectEvent>(e =>
{
    if (e.Target.ServerId == "dev" && !IsOperator(e.Player.Uid))
        e.Cancel("That server is for staff only.");
    return Task.CompletedTask;
});
Member Description
Player The session.
Original The original target as chosen by routing (read-only).
Target Get or set to reroute to a different backend.
Reason Why this connect is happening: "initial connect", or whatever was passed to TransferAsync.
Cancel(reason) Abort this connect attempt. For initial connect this disconnects the player. For transfers it cancels the transfer.

ServerPostConnectEvent

Fires after a successful upstream connection is established. Use for logging, counters, or post-connect actions.

Member Description
Player The session.
Server The backend just connected to.
Previous The previous backend, or null for an initial connect.

PlayerDisconnectEvent

Fires when a session ends for any reason: player quit, kicked, transfer completed, network error.

Member Description
Player The session.
BytesC2S Total bytes sent client-to-server across the whole session.
BytesS2C Total bytes sent server-to-client.

ServerKickedEvent

Fires when the backend drops a live session without the player or proxy initiating it. Covers backend crashes, admin kicks from the game server, and unexpected disconnects during play.

Member Description
Player The session.
Server The backend that closed the connection.

PlayerTransferredEvent

Fires after a redirect or seamless transfer completes successfully from the proxy side.

Member Description
Player The session.
From The source backend, or null for an initial connect.
To The destination backend.
Mode "redirect" or "seamless".

Hot reload behaviour

When nimctl reload runs:

  1. Every plugin's Shutdown() is called.
  2. All event subscriptions are cleared from the event bus.
  3. Each plugin's AssemblyLoadContext is unloaded (plugins use collectible contexts).
  4. The GC runs to release Windows file locks on the DLLs.
  5. Plugins are re-discovered and loaded fresh - Initialize is called again.

Avoid storing state in static fields that you do not want to survive hot-reloads. Static fields persist across reloads because the process does not restart - only the plugin assembly context is rebuilt.


Deploying a plugin

Drop the DLL and manifest into the plugins/ directory:

plugins/
  MyPlugin.dll
  MyPlugin.plugin.json

Pick it up without restarting:

nimctl reload

You should see:

12:00:00 [nimbus] plugins: my-plugin, other-plugin

Clone this wiki locally