-
-
Notifications
You must be signed in to change notification settings - Fork 2
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.
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.
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. |
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() { }
}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.
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.
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);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.
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. |
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. |
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. |
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. |
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. |
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. |
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". |
When nimctl reload runs:
- Every plugin's
Shutdown()is called. - All event subscriptions are cleared from the event bus.
- Each plugin's
AssemblyLoadContextis unloaded (plugins use collectible contexts). - The GC runs to release Windows file locks on the DLLs.
- Plugins are re-discovered and loaded fresh -
Initializeis 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.
Drop the DLL and manifest into the plugins/ directory:
plugins/
MyPlugin.dll
MyPlugin.plugin.json
Pick it up without restarting:
nimctl reloadYou should see:
12:00:00 [nimbus] plugins: my-plugin, other-plugin