Plugins
The plugin lifecycle, dependency injection, the plugin scope, and hot reload.
A plugin is an ordinary .NET assembly with a class that derives from Plugin. Palforge discovers it on
disk, constructs it through dependency injection, wires up its hooks and commands, and drives its
lifecycle — all inside the game process.
The Plugin base class
using Microsoft.Extensions.Logging;
using Palforge.Plugins;
using Palforge.Plugins.Attributes;
[Plugin("com.example.myplugin", Name = "My Plugin", Author = "you", Version = "1.0.0")]
public sealed class MyPlugin : Plugin
{
protected override void OnStart() { /* set things up */ }
protected override void OnStop() { /* tear things down */ }
}Plugin gives you four protected accessors, live once the host has attached the plugin:
| Member | Type | What it is |
|---|---|---|
Log | ILogger | The plugin's own logger, written to Palforge\Logs\<Name>-<date>.log. |
Unreal | UnrealApi | The reflection API — find, read and write live game objects. |
Scope | PluginScope | The registration surface — hooks, watchers, timers, all auto-cleaned. |
Services | IServiceProvider | The plugin's DI container. |
Log, Unreal, Scope and Services are not available in the constructor or field initializers —
they throw until the host attaches the plugin. Take what you need through the constructor (see
Dependency injection) or use OnStart.
The [Plugin] attribute
[Plugin(id)] marks your plugin class and carries optional metadata:
[Plugin("com.example.myplugin", Name = "My Plugin", Author = "you", Version = "1.0.0")]Only id is required. The attribute is itself optional — a plugin with none still loads, taking its
id and name from its folder. Version falls back to the assembly version, then 0.0.0.
Discovery
Palforge loads plugins from Palforge\Plugins\, one folder per plugin. For a plugin named MyPlugin:
Palforge\Plugins\MyPlugin\
├─ MyPlugin.dll required — the assembly name MUST equal the folder name
├─ MyPlugin.cfg optional — per-plugin INI configuration
├─ MyPlugin.deps.json used to resolve the plugin's private dependencies
└─ … any private dependency / native DLLsPalforge looks for Plugins\<Name>\<Name>.dll, then finds the first non-abstract Plugin subclass in
it. If the assembly name doesn't match the folder, or there's no Plugin subclass, the plugin is
skipped with a warning — one bad plugin never stops the others from loading.
Lifecycle
The order is always the same:
construct (DI) → Attach (Log/Unreal/Scope/Services become live) → OnStart → … running … → OnStopOnStart runs on the game thread, so you can read and write game
state directly from it. Do your setup there; do your teardown in OnStop.
Dependency injection
Palforge builds a fresh service container per plugin. Your plugin type is registered automatically, so
its constructor is injected — you can take UnrealApi, PluginScope, IConfiguration,
ILogger<T>, or anything you register yourself.
To register your own services, ship one PluginConfiguration subclass alongside your plugin. Palforge
finds it, instantiates it, and calls ConfigureServices before it builds the container:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Palforge.Plugins;
public sealed class MyPluginConfiguration : PluginConfiguration
{
protected override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<GreetingService>();
services.AddSingleton(new WelcomeOptions(configuration["Welcome:Message"] ?? "Welcome!"));
}
}Then take the service through your plugin's constructor:
[Plugin("com.example.myplugin")]
public sealed class MyPlugin : Plugin
{
private readonly GreetingService _greeting;
public MyPlugin(GreetingService greeting) => _greeting = greeting; // injected
protected override void OnStart() => _greeting.Greet(); // Log/Unreal/Scope live here
}Per-plugin configuration
The IConfiguration handed to ConfigureServices (and injectable into your plugin) is an INI file
named after the plugin folder: Plugins\<Name>\<Name>.cfg. It is optional — a missing file yields an
empty configuration — and it reloads on change. So MyPlugin.cfg:
[Welcome]
Message=Welcome to the server!is read as configuration["Welcome:Message"].
The plugin scope
Scope (a PluginScope) is how a plugin observes and reacts to the game. Every registration returns
an IDisposable and is revoked automatically when the plugin stops — you rarely need to dispose them
yourself.
| Method | Fires when |
|---|---|
OnNewObject<T>(Action<T>) / OnNewObject(string className, Action<UObject>) | A new object of that class (or a subclass) is created. |
OnMapLoaded(Action<UObject>) | A world / map loads (also fires immediately if one is already in play). |
OnTick(Action) | Every game tick, on the game thread. |
AfterFrames(int, Action) / EveryFrames(int, Action) | After / every N frames. |
After(TimeSpan, Action) / Every(TimeSpan, Action) | After / every interval. |
Hook(className, functionName, prefix, postfix, includeOverrides) / HookByName(...) | Manual hooks. |
Track(IDisposable) | Hand the scope any disposable of your own to auto-clean on stop. |
protected override void OnStart()
{
Scope.OnMapLoaded(gameState => Log.LogInformation("Map loaded: {Name}", gameState.Name));
Scope.OnNewObject("PalPlayerCharacter", player => Log.LogInformation("Spawned: {Name}", player.Name));
Scope.Every(TimeSpan.FromMinutes(5), () => Log.LogInformation("5-minute heartbeat"));
}All scope callbacks run on the game thread, and a throw inside one is caught and logged rather than crashing the server.
Hooks and commands
You don't register hooks or commands manually. Any [PreHook]/[PostHook] method and any
ChatCommandModule in your assembly is discovered and bound for you at load — and unbound at unload.
See Hooks and Commands.
Unload and reload
Each plugin loads into its own collectible AssemblyLoadContext, so Palforge can unload and reload
it without restarting the server. Admins drive this from chat:
!plugin list
!plugin load MyPlugin
!plugin reload MyPlugin
!plugin unload MyPluginOn unload, Palforge stops the plugin (OnStop), revokes every scope registration (newest-first),
disposes the container, then unloads the context and verifies it is actually garbage-collected.
The load context is only collected if nothing outside the scope still references the plugin. If you
start your own thread, Task, or timer, stop it in OnStop — otherwise the old assembly stays pinned
in memory and Palforge logs that the plugin could not be collected. Prefer Scope.Every/Scope.OnTick
over your own threads; they're revoked for you.