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:
```xml
net10.0
enable
MyPlugin
path/to/Nimbus.Api.dll
```
**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 `.plugin.json` file next to your DLL in the `plugins/` directory:
```json
{
"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
```csharp
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:
```csharp
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(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.
```csharp
public interface IProxyApi
{
EventBus Events { get; }
IEnumerable Players { get; }
IPlayer? TryGetPlayer(long sessionId);
IPlayer? FindPlayerByUid(string uid);
IPlayer? FindPlayerByName(string name);
Task 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
```csharp
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 TransferAsync(IServerInfo target, string? reason = null);
Task 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
```csharp
public interface IServerInfo
{
string ServerId { get; }
string Host { get; }
int Port { get; }
}
```
Construct a target directly when you already know the address:
```csharp
var target = new ServerInfo("hub", "127.0.0.1", 42421);
await player.TransferAsync(target, "redirect", "sending to hub");
```
Or resolve by ID:
```csharp
var target = await api.ResolveServerAsync("hub", default);
if (target != null)
await player.TransferAsync(target);
```
---
## Events
Subscribe with `api.Events.Subscribe(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.
```csharp
api.Events.Subscribe(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.
```csharp
api.Events.Subscribe(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.
```csharp
api.Events.Subscribe(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:
```bash
nimctl reload
```
You should see:
```
12:00:00 [nimbus] plugins: my-plugin, other-plugin
```