Skip to content

Plugin Lifecycle

Every RocketMod plugin follows a predictable lifecycle from the moment RocketMod discovers its assembly to the moment the plugin is removed from memory. Understanding this lifecycle is essential for writing plugins that initialize correctly, clean up after themselves, and survive reload cycles without leaking state or double-subscribing to events.

This article covers the complete lifecycle of a RocketMod plugin — from assembly discovery through load, active running, and unload — including the lifecycle events that fire at each transition. The 57 Studios™ team has debugged enough reload-corrupted plugin states to document not just the order of operations but the invariants that hold at each step.

Diagram showing the RocketMod plugin state machine from discovered to unloaded

Prerequisites

  • A working RocketMod plugin project. See Creating Your First RocketMod Plugin.
  • Familiarity with the RocketPlugin<TConfig> base class and Load() / Unload() methods.
  • Access to a test server for reload testing.

The lifecycle state machine

RocketMod manages plugins through a state machine with five states:

Discovered → Loading → Loaded → Active → Unloading → Unloaded

The Reload operation cycles through Unloading → Unloaded → Loading → Loaded on the same plugin instance.

Each state corresponds to a method call or event firing. The plugin code runs inside these transitions and must not assume a steady state outside of Active.

State descriptions

StateMeaningWhat the plugin can do
DiscoveredRocketMod found the assembly in Rocket/Plugins/Nothing — the class has not been instantiated yet
LoadingThe constructor and Load() are executingInitialize state, subscribe to events, register commands
LoadedLoad() completed successfullyPlugin is running normally; event handlers fire, commands work
UnloadingUnload() is executingDeregister events, flush state, release resources
UnloadedUnload() completed; plugin is inertNothing — the instance may be garbage collected

Constructor phase

Before Load() runs, RocketMod instantiates the plugin class using its parameterless constructor. This happens after assembly discovery and before any lifecycle method is called.

csharp
public class MyLifecyclePlugin : RocketPlugin<MyLifecyclePluginConfiguration>
{
    public MyLifecyclePlugin()
    {
        Rocket.Core.Logging.Logger.Log("[MyLifecyclePlugin] Constructor called.");
    }
}

What the constructor should do

  • Store constructor parameters only. Since RocketPlugin<TConfig> has no constructor parameters, there is nothing to store.
  • Initialize readonly fields that do not depend on configuration or runtime state.
  • Do NOT subscribe to events in the constructor. Events fired during construction arrive before the plugin is fully initialized, and the subscription pattern in Load() handles the lifecycle correctly.

What the constructor should NOT do

  • Do NOT access Configuration.Instance. The configuration file has not been deserialized yet. Accessing configuration in the constructor reads a null reference.
  • Do NOT call R.Permissions, R.Commands, or R.Plugins. RocketMod's own services may not be fully initialized when the constructor runs.
  • Do NOT start timers, open file handles, or allocate scarce resources. The plugin may be constructed but never loaded if an earlier plugin fails to initialize.

Load phase

Load() is called after the plugin is constructed and its configuration is deserialized. This is where the plugin sets up its runtime state.

csharp
protected override void Load()
{
    Rocket.Core.Logging.Logger.Log("[MyLifecyclePlugin] Load() called.");

    Instance = this;

    // Subscribe to events
    UnturnedPlayerEvents.OnPlayerConnected += OnPlayerConnected;
    UnturnedPlayerEvents.OnPlayerDisconnected += OnPlayerDisconnected;

    // Read configuration
    int maxPlayers = Configuration.Instance.MaxPlayers;

    Rocket.Core.Logging.Logger.Log(
        $"[MyLifecyclePlugin] Loaded. Max players: {maxPlayers}");
}

Order of operations in Load

  1. The configuration file is deserialized into Configuration.Instance. If the file does not exist, LoadDefaults() is called and the defaults are written to disk.
  2. Load() executes on the plugin instance.
  3. During Load(), the plugin subscribes to events, sets the static Instance singleton, initializes caches or data structures, and logs the successful load.
  4. OnPluginLoading fires during the Load() method, after the plugin's own initialization code but before Load() returns. This event is part of Rocket.Core.Plugins.Events and allows other systems to react to the plugin loading. The event receives the plugin instance and can inspect its state, though at this point the plugin's event subscriptions may not all be registered yet.

What to put in Load

  • Event subscriptions: subscribe to all UnturnedPlayerEvents, UnturnedServer, and custom events here. Match every += with a -= in Unload().
  • Singleton assignment: set Instance = this so commands and other classes can access the plugin.
  • Cache initialization: pre-load lookup tables, translation files, and frequently-used configuration values into memory for fast access during command execution.
  • Timers: create and start repeating tasks using UnityEngine.GameObject components or InvokeRepeating.

What NOT to put in Load

  • Heavy synchronous I/O: loading a large database or downloading files from the internet blocks the server's main thread. RocketMod's Load() is synchronous, and the server cannot process ticks until it returns.
  • Code that depends on other plugins: there is no guarantee that another plugin has been loaded before yours, even if your plugin physically appears first in the directory listing. RocketMod does not enforce plugin load order. If your plugin needs another plugin's functionality, use RocketPluginManager.GetPlugin() with a null check and handle the missing-plugin case gracefully.
  • Throwing exceptions: if Load() throws, RocketMod marks the plugin as failed and does not retry. Wrap critical initialization in try-catch and log the error so you can diagnose it from the log file.

OnPluginLoading event

The OnPluginLoading event is part of Rocket.Core.Plugins.Events. It fires during the Load() method, as part of the loading process. This is important for plugin framework systems that need to intercept or observe plugin loading as it happens.

csharp
using Rocket.Core.Plugins.Events;
using Rocket.Core.Plugins;

// Subscribe to the event (typically from another system or loader)
RocketPluginManager.OnPluginLoading += (plugin) =>
{
    Rocket.Core.Logging.Logger.Log(
        $"[Loader] Plugin loading: {plugin.Name}");
};

The event fires at this specific point in the load sequence:

  1. Plugin configuration is loaded.
  2. Plugin Load() begins executing.
  3. OnPluginLoading fires — other systems can observe the loading plugin.
  4. Plugin Load() continues with the remaining initialization.
  5. Load() returns.
  6. Plugin enters Loaded state.

Because OnPluginLoading fires inside Load(), any code that runs in response to this event executes during the plugin's own initialization. The plugin is not yet in the Loaded state, so some services may not be fully available.

When to hook OnPluginLoading

This event is used by cross-plugin systems, load balancers, and monitoring tools that need to track which plugins are loading. Individual plugins should not hook OnPluginLoading to run their own initialization — that code belongs in Load().

Active phase

After Load() completes without throwing, the plugin enters the Loaded state. It is now fully operational:

  • Event handlers fire in response to server activity.
  • Commands are registered and respond to player and console input.
  • Configuration is readable through Configuration.Instance.
  • The Instance singleton is set and accessible to command classes.

The plugin is not notified of entering Active

There is no OnPluginActivated event or method. The transition from Loaded to Active is implicit — it happens when Load() returns. If you need to execute code after all plugin initialization is complete but before the server processes the next tick, use a one-frame delay with Invoke:

csharp
protected override void Load()
{
    // Schedule post-load initialization
    Invoke(nameof(PostLoadInit), 0f);
}

private void PostLoadInit()
{
    // This runs on the next Unity frame, after Load() has returned.
    Rocket.Core.Logging.Logger.Log("[MyLifecyclePlugin] Post-load init.");
}

This pattern is commonly used for plugins that need to query other plugins at startup, because it guarantees all plugins have finished loading before the deferred code runs.

Unload phase

Unload() is called when the plugin is being shut down — either because the server is stopping or because an administrator ran /rocket unload <name> or /rocket reload <name>.

csharp
protected override void Unload()
{
    Rocket.Core.Logging.Logger.Log("[MyLifecyclePlugin] Unload() called.");

    // Unsubscribe from events
    UnturnedPlayerEvents.OnPlayerConnected -= OnPlayerConnected;
    UnturnedPlayerEvents.OnPlayerDisconnected -= OnPlayerDisconnected;

    // Release singleton
    Instance = null;

    // Flush any pending state
    Configuration.Save();
}

Order of operations in Unload

  1. OnPluginUnloading fires — before Unload() is called but after the decision to unload has been made. Other systems can observe the unloading plugin while it is still fully operational.
  2. Unload() executes.
  3. RocketMod removes the plugin's command registrations.
  4. The plugin enters Unloaded state. The instance is no longer referenced by RocketMod and becomes eligible for garbage collection.

What to put in Unload

  • Event unsubscription: every += in Load() must have a matching -= here. Missing unsubscriptions cause double-firing when the plugin is reloaded.
  • Singleton nullification: set Instance = null so stale references in command classes throw a clear error instead of silently operating on dead state.
  • Configuration save: if the plugin modified configuration values at runtime (e.g., tracking statistics that should persist), call Configuration.Save() to flush them to disk.
  • Timer cancellation: stop any InvokeRepeating calls or coroutines. Unity's MonoBehaviour destruction handles some cleanup, but explicit cancellation is more reliable.
  • File handle release: close any open file streams, database connections, or network sockets.

Common Unload mistakes

MistakeSymptomFix
Missing -= on eventsAfter /rocket reload, event fires twiceAdd the matching -= for every +=
Instance = null before event cleanupOther code accessing Instance during event unsubscription gets nullSet Instance = null AFTER all unsubscriptions
Not saving configurationConfiguration changes lost on reloadCall Configuration.Save() in Unload()
Not canceling async operationsOrphaned threads continue after reloadStore a CancellationTokenSource and cancel it in Unload()

Reload phase

/rocket reload <pluginname> triggers a full unload and load cycle on the same plugin instance:

Unload() → OnPluginUnloading fires → Unloaded → OnPluginLoading fires → Load() → Loaded

The reload operation is atomic from RocketMod's perspective: either both Unload() and Load() succeed, or the plugin is left in a safe state. If Unload() throws, the plugin is marked as failed and Load() is not called. If Load() throws after a successful Unload(), the plugin is left in the Unloaded state.

Reload safety checklist

Before supporting reload operations on your plugin, verify these invariants:

  1. Event parity: run a reload twice in a row and confirm the plugin works correctly after each reload. Double-firing is the most common reload bug.
  2. Memory growth: check the server's memory usage before and after five consecutive reloads. If memory grows, the plugin is leaking event subscriptions or object references.
  3. Configuration persistence: change a configuration value, reload the plugin, and confirm the new value is still active. If the configuration reset, LoadDefaults() is overwriting the saved configuration.

Lifecycle events reference

RocketMod provides two lifecycle events through RocketPluginManager:

EventFiresPurpose
OnPluginLoadingDuring Load()Cross-plugin load notification
OnPluginUnloadingBefore Unload()Cross-plugin unload notification

These events are static on RocketPluginManager and are not tied to any single plugin instance. They fire for every plugin load and unload on the server.

Subscribing to lifecycle events from another plugin

csharp
public class PluginMonitor : RocketPlugin<PluginMonitorConfiguration>
{
    protected override void Load()
    {
        Rocket.Core.Plugins.RocketPluginManager.OnPluginLoading += OnAnyPluginLoading;
        Rocket.Core.Plugins.RocketPluginManager.OnPluginUnloading += OnAnyPluginUnloading;
    }

    protected override void Unload()
    {
        Rocket.Core.Plugins.RocketPluginManager.OnPluginLoading -= OnAnyPluginLoading;
        Rocket.Core.Plugins.RocketPluginManager.OnPluginUnloading -= OnAnyPluginUnloading;
    }

    private void OnAnyPluginLoading(IRocketPlugin plugin)
    {
        Rocket.Core.Logging.Logger.Log(
            $"[Monitor] Plugin loading: {plugin.Name}");
    }

    private void OnAnyPluginUnloading(IRocketPlugin plugin)
    {
        Rocket.Core.Logging.Logger.Log(
            $"[Monitor] Plugin unloading: {plugin.Name}");
    }
}

The event handlers receive the IRocketPlugin instance of the plugin being loaded or unloaded. You can cast it to the concrete type if you know the assembly, but doing so creates a tight coupling between plugins.

Plugin discovery timing

When the server starts, RocketMod discovers plugins in this order:

  1. RocketMod core initializes (configuration, permissions, translations).
  2. RocketMod scans Rocket/Plugins/ for .dll files.
  3. For each DLL, RocketMod loads the assembly and searches for types inheriting from RocketPlugin<TConfig>.
  4. Each discovered plugin is instantiated and Load() is called.
  5. Plugins are loaded in file-system order (alphabetical by filename).

No built-in load order

RocketMod does not guarantee load order between plugins. If Plugin A depends on Plugin B being loaded first, the server operator must ensure this by naming conventions (e.g., 01_CorePlugin.dll, 02_MyPlugin.dll), but this is a convention, not a framework guarantee. To handle the general case, use a deferred initialization pattern:

csharp
protected override void Load()
{
    Invoke(nameof(DeferredInit), 1f);
}

private void DeferredInit()
{
    var otherPlugin = Rocket.Core.Plugins.RocketPluginManager
        .GetPlugin("OtherPluginName") as OtherPlugin;

    if (otherPlugin != null)
    {
        // Other plugin is available. Initialize cross-plugin features.
    }
    else
    {
        Rocket.Core.Logging.Logger.LogWarning(
            "[MyPlugin] OtherPlugin not found. Cross-plugin features disabled.");
    }
}

The one-second delay ensures all plugins have finished loading before the deferred initialization runs. Adjust the delay based on your server's plugin load time.

Complete lifecycle diagram

Testing lifecycle behavior

Reload test

Write a test plugin that logs every lifecycle method call:

csharp
public class LifecycleTestPlugin : RocketPlugin<LifecycleTestPluginConfiguration>
{
    private int _loadCount = 0;

    public LifecycleTestPlugin()
    {
        Rocket.Core.Logging.Logger.Log("[LifecycleTest] Constructor.");
    }

    protected override void Load()
    {
        _loadCount++;
        Rocket.Core.Logging.Logger.Log(
            $"[LifecycleTest] Load() called. Load count: {_loadCount}");
    }

    protected override void Unload()
    {
        Rocket.Core.Logging.Logger.Log(
            $"[LifecycleTest] Unload() called. Load count was: {_loadCount}");
    }
}

Deploy and run these commands in sequence:

/rocket reload LifecycleTestPlugin
/rocket reload LifecycleTestPlugin
/rocket reload LifecycleTestPlugin

The expected log output shows three clean load-unload cycles with incrementing load counts. If the load count increases faster than expected, Unload() is not being called.

Crash recovery test

Force an exception in Load() and confirm the plugin enters the failed state:

csharp
protected override void Load()
{
    throw new InvalidOperationException("Intentional crash in Load().");
}

After deploying, the server log shows:

[LifecycleTestPlugin] Failed to load: Intentional crash in Load().

The plugin appears in /rocket plugins with a failed indicator but is not operational. Running /rocket reload LifecycleTestPlugin does not retry the load — RocketMod only retries after a server restart.

Error handling across lifecycle

Load errors

If Load() throws an exception, RocketMod catches it, logs the error, and marks the plugin as failed. The plugin instance is NOT retained in the active plugin list, so no event handlers or commands from this plugin are registered. The assembly remains loaded in memory but no plugin code runs.

Unload errors

If Unload() throws, RocketMod logs the error but continues with the unload. The plugin is removed from the active list regardless of the exception. This means an Unload() exception can leave event subscriptions active if the exception occurs before the -= lines execute. Always structure Unload() with cleanup first, logging last:

csharp
protected override void Unload()
{
    try
    {
        // Cleanup — this must run even if logging fails below
        UnturnedPlayerEvents.OnPlayerConnected -= OnPlayerConnected;
        Instance = null;
        Configuration.Save();
    }
    catch (Exception ex)
    {
        Rocket.Core.Logging.Logger.LogError(
            $"[MyPlugin] Unload error (continuing): {ex.Message}");
    }
}

This ensures cleanup completes before any logging, which may itself throw if the logger is being shut down.

Multi-plugin lifecycle coordination

When multiple plugins are installed, their lifecycle methods execute sequentially. Plugin A's Load() runs to completion before Plugin B's Load() begins. The same applies to Unload() — plugins unload in reverse order of their load sequence.

Detecting peer plugin state from lifecycle

If your plugin needs to know whether another plugin has finished loading, use the deferred initialization pattern with a frame delay:

csharp
protected override void Load()
{
    // Schedule peer detection after all plugins have loaded
    Invoke(nameof(DetectPeers), 0f);
}

private void DetectPeers()
{
    var target = RocketPluginManager.GetPlugin("EconomyPlugin");
    if (target != null && target.IsLoaded)
    {
        Logger.Log("[MyPlugin] EconomyPlugin is available. Enabling economy features.");
        _economyAvailable = true;
    }
    else
    {
        Logger.Log("[MyPlugin] EconomyPlugin not found. Economy features disabled.");
        _economyAvailable = false;
    }
}

Lifecycle event forwarding

Some plugin architectures need a central coordinator that observes all plugin lifecycle events. The OnPluginLoading and OnPluginUnloading global events make this possible. A coordinator plugin subscribes to these events once (in its own Load) and forwards them to interested subsystems.

Graceful degradation when peers are missing

When your plugin depends on another plugin that may or may not be loaded, design the dependency as optional. Use a feature flag that disables dependent features when the peer is absent, rather than failing entirely.

Testing lifecycle behavior with a test harness

To validate that your plugin's lifecycle is correct, create a test plugin that measures timing and state transitions.

Lifecycle timing plugin

csharp
public class LifecycleTimer : RocketPlugin<LifecycleTimerConfiguration>
{
    private DateTime _loadStart;
    private DateTime _loadEnd;
    private DateTime _unloadStart;

    protected override void Load()
    {
        _loadStart = DateTime.UtcNow;
        Logger.Log("[LifecycleTimer] Load started.");

        // Simulate initialization work
        System.Threading.Thread.Sleep(100);

        _loadEnd = DateTime.UtcNow;
        Logger.Log($"[LifecycleTimer] Load completed in " +
            $"{( _loadEnd - _loadStart).TotalMilliseconds}ms.");
    }

    protected override void Unload()
    {
        _unloadStart = DateTime.UtcNow;
        Logger.Log("[LifecycleTimer] Unload started.");

        Logger.Log($"[LifecycleTimer] Plugin was active for " +
            $"{( _unloadStart - _loadEnd).TotalSeconds}s.");

        Logger.Log($"[LifecycleTimer] Unload completed in " +
            $"{( DateTime.UtcNow - _unloadStart).TotalMilliseconds}ms.");
    }
}

Verifying event subscription parity

The most important lifecycle test is verifying that every += has a matching -=. Create a plugin that subscribes to an event and counts how many times it fires. Run two consecutive reloads. If the event fires twice as often after the second reload, you have a subscription leak.

csharp
private int _eventFireCount = 0;
private bool _wasReloaded = false;

protected override void Load()
{
    UnturnedPlayerEvents.OnPlayerConnected += OnPlayerConnected;
    Logger.Log("[LeakTest] Subscribed. Current count = " +
        $"{_eventFireCount}");

    if (_wasReloaded)
    {
        // If the count is higher than the number of players who actually joined,
        // the subscription was not cleaned up during Unload.
        Logger.Log("[LeakTest] WARNING: If count doubled since reload, " +
            "subscription leak detected.");
    }
}

protected override void Unload()
{
    _wasReloaded = true;
    UnturnedPlayerEvents.OnPlayerConnected -= OnPlayerConnected;
}

private void OnPlayerConnected(UnturnedPlayer player)
{
    _eventFireCount++;
    Logger.Log($"[LeakTest] Event fired. Total: {_eventFireCount}");
}

Performance profiling during lifecycle

Load time benchmarks

A plugin's Load() runs on the main thread and blocks server ticks until it returns. Measure your load time and keep it under 500ms for a good player experience. If load time exceeds 2000ms (2 seconds), players may time out during server startup.

Load timeImpactRecommendation
< 100msImperceptibleNo action needed
100ms — 500msNoticeable during server startAcceptable for most plugins
500ms — 2000msServer startup delayOptimize — defer heavy I/O
> 2000msPlayers may time outMove work to deferred initialization

Deferred initialization pattern for slow operations

When your plugin needs to load a large configuration, populate a cache, or validate network resources, defer the heavy work:

csharp
protected override void Load()
{
    // Fast — completes immediately
    Instance = this;
    Logger.Log("[MyPlugin] Core initialization complete.");

    // Schedule heavy work one frame later
    Invoke(nameof(DeferredHeavyInit), 0f);
}

private void DeferredHeavyInit()
{
    Logger.Log("[MyPlugin] Loading database...");
    // Database connection, cache population, network calls
    Logger.Log("[MyPlugin] Heavy initialization complete.");
}

This pattern gets the plugin registered and operational within the first tick, then performs slow operations after the server is already responsive.

Frequently asked questions

Does OnPluginLoading fire during Load or after it?

OnPluginLoading fires during Load(), as part of the loading process. It is invoked by the RocketMod framework while Load() is executing, providing an observation point for cross-plugin systems. Plugin authors should not rely on this event for their own initialization — Load() is the correct place for that.

What happens if I do not call Configuration.Save() in Unload?

The configuration values are cached in memory but not written to disk. If the plugin modified any configuration values at runtime (e.g., tracking an internal counter stored in config), those changes are lost on reload or server restart. The configuration file on disk retains the values from the last Save() call. Call Configuration.Save() in Unload() if your plugin mutates configuration values at runtime.

Can another plugin prevent mine from loading?

No. Each plugin's Load() is independent. If Plugin A throws in Load(), only Plugin A is marked as failed. Other plugins in the same server load normally, even if they were discovered after the failing plugin.

Is there a guaranteed load order?

No. RocketMod discovers plugins in file-system order, which is effectively alphabetical by filename on most file systems. There is no dependency-declaration system like OpenMod's [PluginDependency]. If your plugin requires another plugin, defer the dependent initialization by one frame using Invoke and check RocketPluginManager.GetPlugin().

Does rocket reload preserve the plugin instance?

Yes. RocketMod calls Unload() followed by Load() on the same instance. The object is not destroyed between calls. This means instance fields (like _loadCount in the test example above) persist across reload cycles. If you need a truly fresh state on each load, reset all fields at the start of Load().

Unload guarantees and timing

RocketMod guarantees that Unload() is called in these scenarios:

ScenarioUnload called?Notes
Server shutdown (clean)YesRocketMod calls Unload on every loaded plugin
Server crashNoProcess terminates without cleanup
/rocket unload <plugin>YesImmediate unload of the specified plugin
/rocket reload <plugin>YesFollowed immediately by Load() on same instance
/rocket reload * (all)YesAll plugins unloaded and reloaded in sequence
Plugin throws in constructorNoPlugin instance never entered Loaded state
Plugin throws in Load()Yes (via reload)Unload called only if reload is triggered

Unload timeout

RocketMod does not enforce a timeout on Unload(). If Unload() hangs (e.g., waiting on a network resource that never responds), the plugin never finishes unloading, and subsequent operations (server shutdown, reload of other plugins) are blocked indefinitely. Always use a timeout pattern for unload operations that could block:

csharp
protected override void Unload()
{
    var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));

    try
    {
        // Attempt cleanup with timeout
        Task.Run(() => FlushPendingWrites(), cancellation.Token)
            .Wait(cancellation.Token);
    }
    catch (OperationCanceledException)
    {
        Logger.LogWarning("[MyPlugin] Unload timed out. Continuing.");
    }

    // Always run synchronous cleanup regardless of timeout
    UnturnedPlayerEvents.OnPlayerConnected -= OnPlayerConnected;
    Instance = null;
}

Document history

VersionDateAuthorNotes
1.02026-07-2757 StudiosInitial publication. Full lifecycle reference.