Plugin Lifecycle
This guide explains the complete lifecycle of a Deadworks plugin, from loading to unloading.
Lifecycle Flow
Server Start
│
├── OnPrecacheResources() ← Precache particles, models, heroes
│
├── OnLoad(isReload: false) ← Plugin first loaded
│
├── OnStartupServer() ← Server map loaded, set convars here
│
│ ┌─────────────────────────────┐
│ │ SERVER RUNNING │
│ │ │
│ │ OnClientConnect() │
│ │ OnClientPutInServer() │
| | ... other hooks ... |
│ └─────────────────────────────┘
│
├── OnUnload() ← Plugin unloaded
│
└── (Hot-reload) → OnLoad(isReload: true)
Startup Phase
OnPrecacheResources
Called during map load. Must precache all resources (particles, models, etc.) here.
public override void OnPrecacheResources()
{
Precache.AddResource("particles/upgrades/mystical_piano_hit.vpcf");
}
See Precaching.
OnLoad
Called when the plugin is loaded. The isReload parameter is true during hot-reload.
public override void OnLoad(bool isReload)
{
Console.WriteLine($"[{Name}] Loaded! (reload={isReload})");
if (!isReload)
{
// First-time initialization only
}
}
OnStartupServer
Called when the server starts a new map. Ideal for setting game convars:
public override void OnStartupServer()
{
ConVar.Find("citadel_trooper_spawn_enabled")?.SetInt(0);
ConVar.Find("citadel_allow_duplicate_heroes")?.SetInt(1);
}
Runtime Phase
During runtime, your plugin responds to events through hooks and registered commands.
Event Processing Order
- Entity events — creation, spawn, deletion, touch
- Player events — connect, disconnect, commands
- Gameplay events — damage, currency, chat
- Frame events —
OnGameFrameevery tick
Hot-Reloading
When a plugin is hot-reloaded:
OnUnload()is called on the old instanceOnLoad(isReload: true)is called on the new instance- All registered commands and hooks are re-registered
Important: Clean up timers and hooks in OnUnload() to avoid duplicates after reload.
Shutdown Phase
OnUnload
Called when the plugin is unloaded (server shutdown, hot-reload, or manual unload).
public override void OnUnload()
{
Console.WriteLine($"[{Name}] Unloaded!");
// Timers are automatically cleaned up per-plugin
// EntityData stores are automatically cleaned up
}
What's cleaned up automatically:
- Per-plugin timers
EntityData<T>entries (on entity deletion)
What you should clean up manually:
- Dynamic game event listeners (via
IHandle.Cancel()) - Any external resources or connections
Client Lifecycle
Player connects
│
├── OnClientPutInServer() ← Initial connection
│
├── OnClientFullConnect() ← Fully in-game, can interact
│
│ (player is active in-game)
│
└── OnClientDisconnect() ← Player leaves
Example: Player Tracking
private readonly HashSet<int> _activePlayers = new();
public override void OnClientFullConnect(ClientFullConnectEvent args)
{
_activePlayers.Add(args.Slot);
Console.WriteLine($"Player connected: slot {args.Slot}");
}
public override void OnClientDisconnect(ClientDisconnectedEvent args)
{
_activePlayers.Remove(args.Slot);
Console.WriteLine($"Player disconnected: slot {args.Slot}");
}
You may use
Playersinstead to access all players
Async Work — Get Back On the Game Thread
After await, C# may resume on a thread-pool thread. Touching any game object off the main thread will corrupt memory or crash. Always wrap game-touching code in Timer.NextTick(...) after an await:
public override void OnLoad(bool isReload)
{
// OnLoad is not async — kick off the work and don't await
_ = FetchAndAnnounceAsync();
}
private async Task FetchAndAnnounceAsync()
{
using var client = new HttpClient();
var response = await client.GetStringAsync("https://api.example.com/message");
// At this point we may be on a non-game thread.
Timer.NextTick(() =>
{
// Safe to interact with the game here.
});
}
The same rule applies to Task.Delay, Task.Run, file I/O, anything that yields. If you're not sure whether the continuation is on the game thread, route it through Timer.NextTick.
Hot-Reload Gotchas
Hot-reload replaces the plugin assembly while the server keeps running. This is very useful during development, but there are some pitfalls:
- Cancel long-running work in
OnUnload. Per-plugin timers andEntityData<T>entries are cleaned up automatically. Anything else —CancellationTokenSource,FileSystemWatcher, sockets,Timer.Sequencehandles you want to stop — has to be cancelled or disposed manually. - Static state persists. Types in a new load context have fresh statics, but if you've cached anything in a host assembly (shared
DeadworksManaged.Apitypes, for example), it will still be there after a reload. UseisReloadto decide whether to re-initialize. Console.WriteLineduringOnLoadmay vanish on first boot. The console buffer can swallow the first batch of log lines before idling; a reload (hot-reload the plugin, or edit the DLL while the server runs) will make the logs appear. If you need reliable output from first boot, log through a file instead.
Console Output on Windows
If you launch deadworks.exe from Windows Terminal or PowerShell and the console window keeps overwriting its own top line (showing only N/31 on map dl_midtown no matter how far up you scroll), that's a terminal compatibility issue with Deadlock's progress reporting. Launch from cmd.exe (the classic console host) instead and the problem goes away.
See Also
- First Plugin — Starting from
DeadworksPluginBase - Precaching — Resource precaching
- ConVars — ConVar setup in
OnStartupServer - Server Hosting — Running a dedicated server