Skip to content

Player Connect and Disconnect Events

Player connection and disconnection are the two most frequently fired lifecycle events in any Unturned dedicated server running RocketMod. Every plugin that manages player state — permission caching, join announcements, geo-blocking, whitelisting, analytics, or persistent data loading — depends on reliable connection-event handling. Getting the event subscription pattern and data-access timing right is the difference between a plugin that works every time and one that fails intermittently on player join.

This article covers the RocketMod player connection and disconnection event system in depth. It explains the connection lifecycle, the full event sequence from handshake to spawn-ready, the data that is and is not available at each stage, and how to write robust handlers that survive server reloads, player floods, and edge cases like RocketMod reload mid-connection.

57 Studios maintains a suite of server-side plugins for the Horizon Life RP community. The patterns documented here are drawn from production plugin authoring experience, not from documentation alone.

Prerequisites

  • A working Unturned dedicated server with RocketMod installed. See RocketMod and OpenMod Plugin Basics for installation guidance.
  • Visual Studio 2022 with C# support for plugin development.
  • .NET Framework 4.7.2 SDK installed on the development machine.
  • Familiarity with C# delegates and event subscription patterns.
  • Basic knowledge of the RocketPlugin<TConfiguration> lifecycle (Load() / Unload()).
  • Access to the server's Rocket/ directory for plugin deployment and log inspection.

What you'll learn

  • The full sequence of events that fire when a player connects to an Unturned server, from Steam authentication to spawn-ready.
  • How to subscribe to OnPlayerConnected and OnPlayerDisconnected from a RocketMod plugin.
  • The OnPlayerPreConnect cancellation event and how to reject a connection before the player fully joins.
  • Which player properties are available at each stage of the connection lifecycle, including the asynchronous IP resolution caveat.
  • How to cache player data on connect and clean it up on disconnect to prevent memory leaks.
  • How to build a player-data plugin that persists player metadata and loads it on join.
  • Common pitfalls: double-firing after reload, accessing uninitialized player state, and connection-flood edge cases.
  • How to test connection handlers in a local server environment before production deployment.

Connection lifecycle overview

When a player connects to an Unturned server, the engine fires a sequence of events from Steam SDK authentication through to the player being fully spawned in the game world. RocketMod intercepts several points in this sequence through its UnturnedPlayerEvents and UnturnedServer static classes.

The full connection sequence is:

Steam authentication

OnPlayerPreConnect (cancellable — player has not yet joined)

Player object creation

Character data load from disk

OnPlayerConnected (player is now in the game world)

Player spawn-in animation

Player fully interactive

Understanding where your handler runs in this sequence determines what data you can safely access. A handler that runs during OnPlayerPreConnect cannot access the player's inventory or position because those structures have not been initialized yet. A handler that runs during OnPlayerConnected can access most player properties, but some are populated asynchronously.

Event reference table

RocketMod exposes the following connection-related events:

EventFires whenAvailable dataCancellable
OnPlayerPreConnectSteam ticket validated, player object created but not yet added to worldSteam ID, player name, IP addressYes — return false to reject
OnPlayerConnectedPlayer fully added to game world and ready for interactionAll player properties including position, inventory, skills, statsNo
OnPlayerDisconnectedPlayer leaves the server (any reason)Player object still valid; position, inventory, stats readableNo
OnServerShutdownServer is shutting down gracefullyAll player objects still validNo

The two events that cover the majority of plugin use cases are OnPlayerConnected and OnPlayerDisconnected. OnPlayerPreConnect is used for specialized pre-join checks such as whitelisting, IP-based region blocking, or banning.

OnPlayerConnected deep dive

Event signature

csharp
public static event PlayerConnected OnPlayerConnected;
public delegate void PlayerConnected(UnturnedPlayer player);

The handler receives a single UnturnedPlayer parameter representing the player who just joined. The UnturnedPlayer object wraps the underlying SDG.Unturned.Player instance and provides convenience properties for plugin development.

Subscription pattern

Subscribe in the plugin's Load() method and desubscribe in Unload():

csharp
using Rocket.Unturned.Events;
using Rocket.Unturned.Player;

protected override void Load()
{
    UnturnedPlayerEvents.OnPlayerConnected += HandlePlayerConnected;
    Logger.Log("[MyPlugin] Subscribed to OnPlayerConnected.");
}

protected override void Unload()
{
    UnturnedPlayerEvents.OnPlayerConnected -= HandlePlayerConnected;
    Logger.Log("[MyPlugin] Unsubscribed from OnPlayerConnected.");
}

private void HandlePlayerConnected(UnturnedPlayer player)
{
    Logger.Log($"Player connected: {player.DisplayName} ({player.CSteamID})");
}

What is available in the handler

When OnPlayerConnected fires, the following player properties are fully initialized and safe to read:

PropertyTypeAvailableNotes
CSteamIDulongYesSteam64 ID
SteamNamestringYesPlayer's Steam profile name
CharacterNamestringYesIn-game character name
DisplayNamestringYesThe name visible in chat
IsAdminboolYesRocketMod admin status
IsProboolYesUnturned Pro ownership
PositionVector3YesCurrent spawn position
RotationVector2YesCurrent facing direction
PingintYesNetwork latency in milliseconds
HealthbyteYesCurrent health value
FoodbyteYesCurrent food value
WaterbyteYesCurrent water value
VirusbyteYesCurrent virus/radiation level
StaminabyteYesCurrent stamina value
ExperienceuintYesCurrent experience points
SkillsIList<Skill>YesAll skill instances
InventoryPlayerInventoryYesFull inventory access
IPstringYesPlayer's IP address

The IP property is populated asynchronously at the engine level but is available immediately by the time the OnPlayerConnected event fires. This means a geo-block plugin that reads the player's IP inside the connection handler will always have access to the correct address.

Common handler pattern: join announcement

csharp
private void HandlePlayerConnected(UnturnedPlayer player)
{
    string message = Configuration.Instance.JoinMessage
        .Replace("{player}", player.DisplayName)
        .Replace("{count}", UnturnedPlayer.OnlinePlayers.Count.ToString());

    UnturnedChat.Say(message, Palette.SERVER);
}

Common handler pattern: data loading

Many plugins load persistent player data on connect. The connection handler is the correct place to trigger a data load because the player's identity is confirmed and the player is about to become interactive:

csharp
private readonly Dictionary<ulong, PlayerData> _playerCache = new Dictionary<ulong, PlayerData>();

private void HandlePlayerConnected(UnturnedPlayer player)
{
    if (_playerCache.ContainsKey(player.CSteamID.m_SteamID))
    {
        Logger.LogWarning($"Player {player.DisplayName} already has cached data. Reloading.");
        _playerCache.Remove(player.CSteamID.m_SteamID);
    }

    PlayerData data = LoadPlayerData(player.CSteamID.m_SteamID);
    _playerCache.Add(player.CSteamID.m_SteamID, data);
    Logger.Log($"Loaded data for {player.DisplayName}: {data.CurrencyBalance} currency.");
}

private PlayerData LoadPlayerData(ulong steamId)
{
    string path = Path.Combine(Plugin.DataDirectory, $"{steamId}.json");
    if (File.Exists(path))
    {
        string json = File.ReadAllText(path);
        return JsonConvert.DeserializeObject<PlayerData>(json);
    }
    return new PlayerData();
}

OnPlayerPreConnect: cancelling connections before join

Event signature

csharp
public static event PlayerPreConnecting OnPlayerPreConnect;
public delegate bool PlayerPreConnecting(UnturnedPlayer player);

The OnPlayerPreConnect event fires after the player's Steam ticket has been validated but before the player is added to the game world. The handler returns a bool: return true to allow the connection, or false to reject it.

Because the player has not yet joined the game world, certain properties are unavailable:

PropertyAvailable in OnPlayerPreConnectReason
CSteamIDYesSteam ID is known from the authentication ticket
SteamNameYesProfile name is known from Steam
IPYesThe IP is populated asynchronously but resolves before this event fires in normal conditions
PositionNoSpawn point not yet assigned
InventoryNoInventory not yet loaded
SkillsNoSkills not yet initialized
HealthNoVitals not yet initialized

Whitelist implementation

csharp
private HashSet<ulong> _whitelist;

protected override void Load()
{
    _whitelist = LoadWhitelist();
    UnturnedPlayerEvents.OnPlayerPreConnect += OnPlayerPreConnect;
}

protected override void Unload()
{
    UnturnedPlayerEvents.OnPlayerPreConnect -= OnPlayerPreConnect;
}

private bool OnPlayerPreConnect(UnturnedPlayer player)
{
    if (!_whitelist.Contains(player.CSteamID.m_SteamID))
    {
        string ip = player.IP;
        Logger.Log($"Rejected connection from {player.SteamName} ({ip}) — not on whitelist.");
        return false;
    }
    return true;
}

IP-based region filter

csharp
private bool OnPlayerPreConnect(UnturnedPlayer player)
{
    string ip = player.IP;

    if (ip == "127.0.0.1" || ip.StartsWith("192.168."))
    {
        return true;
    }

    GeoLocation location = GeoLookup(ip);

    if (!Configuration.Instance.AllowedRegions.Contains(location.Region))
    {
        Logger.Log($"Blocked connection from {location.Region} for {player.SteamName} ({ip}).");
        return false;
    }

    return true;
}

OnPlayerDisconnected deep dive

Event signature

csharp
public static event PlayerDisconnected OnPlayerDisconnected;
public delegate void PlayerDisconnected(UnturnedPlayer player);

The handler receives the same UnturnedPlayer object that was passed to OnPlayerConnected. The player object remains valid during the handler — you can read the player's position, inventory, stats, and other properties — but you cannot send messages to the player because their connection is already closing.

Common handler pattern: data persistence

csharp
private void HandlePlayerDisconnected(UnturnedPlayer player)
{
    ulong steamId = player.CSteamID.m_SteamID;

    if (_playerCache.TryGetValue(steamId, out PlayerData data))
    {
        data.LastPosition = player.Position;
        data.LastSeen = DateTime.UtcNow;
        SavePlayerData(steamId, data);
        _playerCache.Remove(steamId);
        Logger.Log($"Saved data for {player.DisplayName}. Position: {data.LastPosition}");
    }
    else
    {
        Logger.LogWarning($"Player {player.DisplayName} disconnected but had no cached data.");
    }
}

OnServerShutdown: bulk save on graceful shutdown

When the server shuts down gracefully, RocketMod fires OnServerShutdown. This event is the correct place to flush all cached player data to disk:

csharp
private void HandleServerShutdown()
{
    Logger.Log($"Server shutting down. Saving {_playerCache.Count} player records.");

    foreach (KeyValuePair<ulong, PlayerData> entry in _playerCache)
    {
        SavePlayerData(entry.Key, entry.Value);
    }

    _playerCache.Clear();
    Logger.Log("All player data saved.");
}

Subscribe in Load():

csharp
UnturnedServer.OnServerShutdown += HandleServerShutdown;

Desubscribe in Unload():

csharp
UnturnedServer.OnServerShutdown -= HandleServerShutdown;

Player data caching architecture

A well-structured plugin uses the connection event to load data into a cache and the disconnection event to flush it back to persistent storage. The cache avoids repeated disk I/O during gameplay and lets the plugin respond instantly to permission checks, currency queries, and other data lookups.

Concurrent dictionary for thread safety

Use ConcurrentDictionary for the player cache to avoid race conditions when players connect and disconnect under load:

csharp
using System.Collections.Concurrent;

private ConcurrentDictionary<ulong, PlayerData> _playerCache =
    new ConcurrentDictionary<ulong, PlayerData>();

Cache expiration for stale entries

If a player connects, the cache loads their data, but then the server crashes before the disconnect handler fires, the cache will contain stale data on restart. Always validate cache entries at connection time:

csharp
private void HandlePlayerConnected(UnturnedPlayer player)
{
    ulong steamId = player.CSteamID.m_SteamID;

    if (_playerCache.TryRemove(steamId, out PlayerData staleData))
    {
        Logger.LogWarning($"Cleaned stale cache entry for {player.DisplayName}. " +
            $"Last seen: {staleData.LastSeen}");
    }

    PlayerData freshData = LoadFromDiskOrCreate(steamId);
    _playerCache.TryAdd(steamId, freshData);
}

Connection ordering and timing

When multiple players connect simultaneously, RocketMod processes each connection sequentially on the main Unity thread. Each OnPlayerConnected handler runs to completion before the next player's connection is processed. Long-running handlers block the connection queue and delay spawn-in for all players behind them in the queue.

Handler timing budgets

OperationSafe to run in handlerRecommended pattern
Simple cache insertYesDirect dictionary insert
File read (small, < 1 KB)YesSynchronous File.ReadAllText
File read (large, > 10 KB)With cautionOffload to async thread
HTTP requestNoQueue for background processing
Database writeNoQueue for background processing
Heavy computationNoOffload to task

Background queue pattern for slow operations

csharp
private readonly ConcurrentQueue<Func<Task>> _pendingWork =
    new ConcurrentQueue<Func<Task>>();

private void HandlePlayerConnected(UnturnedPlayer player)
{
    ulong steamId = player.CSteamID.m_SteamID;

    _pendingWork.Enqueue(async () =>
    {
        string remoteData = await FetchRemotePlayerData(steamId);
        _playerCache.TryAdd(steamId, ParseRemoteData(remoteData));
    });

    ProcessPendingWork();
}

private async void ProcessPendingWork()
{
    while (_pendingWork.TryDequeue(out Func<Task> work))
    {
        try
        {
            await work();
        }
        catch (Exception ex)
        {
            Logger.LogError($"Background work failed: {ex.Message}");
        }
    }
}

Connection event log analysis

RocketMod writes connection and disconnection events to the server log by default. The output in Rocket/Rocket.log looks like:

[15:23:41] [Rocket] Player Connected: Butter (76561198012345678)
[15:23:42] [Rocket] Player Connected: Phoenix (76561198087654321)
[15:45:12] [Rocket] Player Disconnected: Butter (76561198012345678)

Custom plugins can log additional context:

csharp
private void HandlePlayerConnected(UnturnedPlayer player)
{
    Logger.Log($"[MyPlugin] {player.DisplayName} connected from {player.IP}." +
        $" Server now has {UnturnedPlayer.OnlinePlayers.Count} players.");
}

Common pitfalls and production failures

Double-firing after reload

Every reload of the plugin calls Load() again, which subscribes the handler. If Unload() does not desubscribe, the old handler remains registered. After two reloads, the handler fires twice. After ten reloads, it fires ten times.

csharp
// WRONG — handler leaks across reloads
protected override void Load()
{
    UnturnedPlayerEvents.OnPlayerConnected += HandlePlayerConnected;
    // Forgot to desubscribe in Unload()
}

// CORRECT
protected override void Load()
{
    UnturnedPlayerEvents.OnPlayerConnected += HandlePlayerConnected;
}

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

NullReferenceException on player object

The UnturnedPlayer parameter in OnPlayerConnected is never null, but its underlying SDG.Unturned.Player reference may be in an edge case where the player disconnects during the same frame as their connection handler fires. Always guard against null when accessing nested properties:

csharp
private void HandlePlayerConnected(UnturnedPlayer player)
{
    if (player?.Player == null)
    {
        Logger.LogWarning("HandlePlayerConnected called with null player reference.");
        return;
    }

    Vector3 pos = player.Position; // Safe after null guard
}

Player cache growth without eviction

If a player connects and the plugin adds their data to the cache, but the disconnect handler fails to remove it (due to an exception, a crash, or a missed code path), the cache grows unbounded. Over days of server operation, this becomes a memory leak.

Always remove cache entries in a finally block:

csharp
private void HandlePlayerDisconnected(UnturnedPlayer player)
{
    ulong steamId = player.CSteamID.m_SteamID;

    try
    {
        if (_playerCache.TryRemove(steamId, out PlayerData data))
        {
            SaveToDisk(steamId, data);
        }
    }
    catch (Exception ex)
    {
        Logger.LogError($"Failed to save data for {player.DisplayName}: {ex.Message}");
    }
}

Connection flood protection

A malicious actor can connect and disconnect rapidly to trigger handlers and consume server resources. Implement a cooldown check in OnPlayerPreConnect:

csharp
private readonly ConcurrentDictionary<ulong, DateTime> _lastConnect =
    new ConcurrentDictionary<ulong, DateTime>();

private bool OnPlayerPreConnect(UnturnedPlayer player)
{
    ulong steamId = player.CSteamID.m_SteamID;

    if (_lastConnect.TryGetValue(steamId, out DateTime lastTime))
    {
        if ((DateTime.UtcNow - lastTime).TotalSeconds < Configuration.Instance.ReconnectCooldownSeconds)
        {
            Logger.LogWarning($"Rejecting rapid reconnect from {player.SteamName}.");
            return false;
        }
    }

    _lastConnect[steamId] = DateTime.UtcNow;
    return true;
}

Building a complete join-data plugin

This section walks through a complete RocketMod plugin that caches player data on join, persists it on disconnect, and exposes a console command to inspect the cache.

Project structure

PlayerDataPlugin/
├── PlayerDataPlugin.csproj
├── PlayerDataPlugin.cs
├── PlayerDataPluginConfiguration.cs
└── Commands/
    └── InspectCacheCommand.cs

PlayerDataPlugin.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net472</TargetFramework>
    <AssemblyName>PlayerDataPlugin</AssemblyName>
    <RootNamespace>PlayerDataPlugin</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="Rocket.Core">
      <HintPath>libs\Rocket.Core.dll</HintPath>
      <Private>false</Private>
    </Reference>
    <Reference Include="Rocket.Unturned">
      <HintPath>libs\Rocket.Unturned.dll</HintPath>
      <Private>false</Private>
    </Reference>
    <Reference Include="Assembly-CSharp">
      <HintPath>libs\Assembly-CSharp.dll</HintPath>
      <Private>false</Private>
    </Reference>
    <Reference Include="Newtonsoft.Json">
      <HintPath>libs\Newtonsoft.Json.dll</HintPath>
      <Private>false</Private>
    </Reference>
  </ItemGroup>
</Project>

PlayerDataPluginConfiguration.cs

csharp
using Rocket.API;

namespace PlayerDataPlugin
{
    public class PlayerDataPluginConfiguration : IRocketPluginConfiguration
    {
        public bool EnableJoinAnnouncements;
        public string JoinMessage;
        public int ReconnectCooldownSeconds;
        public string DataDirectory;

        public void LoadDefaults()
        {
            EnableJoinAnnouncements = true;
            JoinMessage = "Welcome, {player}!";
            ReconnectCooldownSeconds = 5;
            DataDirectory = "PlayerData";
        }
    }
}

PlayerDataPlugin.cs

csharp
using Rocket.Core.Plugins;
using Rocket.Unturned.Events;
using Rocket.Unturned.Player;
using Rocket.Unturned.Chat;
using Rocket.Unturned.Server;
using System;
using System.Collections.Concurrent;
using System.IO;
using Newtonsoft.Json;
using UnityEngine;

namespace PlayerDataPlugin
{
    public class PlayerData
    {
        public Vector3 LastPosition;
        public DateTime LastSeen;
        public int CurrencyBalance;
        public int TotalPlaytimeSeconds;
    }

    public class PlayerDataPlugin : RocketPlugin<PlayerDataPluginConfiguration>
    {
        public static PlayerDataPlugin Instance { get; private set; }

        private ConcurrentDictionary<ulong, PlayerData> _playerCache =
            new ConcurrentDictionary<ulong, PlayerData>();

        private string _dataPath;

        protected override void Load()
        {
            Instance = this;
            _dataPath = Path.Combine(Directory.GetCurrentDirectory(),
                Configuration.Instance.DataDirectory);

            if (!Directory.Exists(_dataPath))
            {
                Directory.CreateDirectory(_dataPath);
            }

            UnturnedPlayerEvents.OnPlayerConnected += HandlePlayerConnected;
            UnturnedPlayerEvents.OnPlayerPreConnect += HandlePlayerPreConnect;
            UnturnedPlayerEvents.OnPlayerDisconnected += HandlePlayerDisconnected;
            UnturnedServer.OnServerShutdown += HandleServerShutdown;

            Logger.Log("[PlayerDataPlugin] Loaded. " +
                $"Data directory: {_dataPath}");
        }

        protected override void Unload()
        {
            UnturnedPlayerEvents.OnPlayerConnected -= HandlePlayerConnected;
            UnturnedPlayerEvents.OnPlayerPreConnect -= HandlePlayerPreConnect;
            UnturnedPlayerEvents.OnPlayerDisconnected -= HandlePlayerDisconnected;
            UnturnedServer.OnServerShutdown -= HandleServerShutdown;

            FlushAllPlayerData();

            Instance = null;
            Logger.Log("[PlayerDataPlugin] Unloaded.");
        }

        private bool HandlePlayerPreConnect(UnturnedPlayer player)
        {
            ulong steamId = player.CSteamID.m_SteamID;

            if (_playerCache.TryGetValue(steamId, out PlayerData existing))
            {
                TimeSpan sinceLast = DateTime.UtcNow - existing.LastSeen;
                if (sinceLast.TotalSeconds < Configuration.Instance.ReconnectCooldownSeconds)
                {
                    Logger.LogWarning(
                        $"Rejecting reconnect from {player.SteamName} " +
                        $"({sinceLast.TotalSeconds:F1}s since last disconnect).");
                    return false;
                }
            }

            return true;
        }

        private void HandlePlayerConnected(UnturnedPlayer player)
        {
            ulong steamId = player.CSteamID.m_SteamID;

            if (_playerCache.TryRemove(steamId, out PlayerData stale))
            {
                Logger.Log($"Cleaned stale cache for {player.DisplayName}.");
            }

            PlayerData data = LoadFromDisk(steamId);
            data.LastSeen = DateTime.UtcNow;
            _playerCache.TryAdd(steamId, data);

            if (Configuration.Instance.EnableJoinAnnouncements)
            {
                string msg = Configuration.Instance.JoinMessage
                    .Replace("{player}", player.DisplayName);
                UnturnedChat.Say(msg, Palette.SERVER);
            }

            Logger.Log($"[PlayerDataPlugin] {player.DisplayName} connected. " +
                $"Currency: {data.CurrencyBalance}, " +
                $"Total playtime: {data.TotalPlaytimeSeconds}s. " +
                $"Players online: {UnturnedPlayer.OnlinePlayers.Count}");
        }

        private void HandlePlayerDisconnected(UnturnedPlayer player)
        {
            ulong steamId = player.CSteamID.m_SteamID;

            try
            {
                if (_playerCache.TryRemove(steamId, out PlayerData data))
                {
                    data.LastPosition = player.Position;
                    data.LastSeen = DateTime.UtcNow;
                    data.TotalPlaytimeSeconds += (int)(DateTime.UtcNow - data.LastSeen).TotalSeconds;
                    SaveToDisk(steamId, data);
                    Logger.Log($"[PlayerDataPlugin] Saved data for {player.DisplayName}.");
                }
            }
            catch (Exception ex)
            {
                Logger.LogError(
                    $"[PlayerDataPlugin] Failed to save {player.DisplayName}: {ex.Message}");
            }
        }

        private void HandleServerShutdown()
        {
            Logger.Log("[PlayerDataPlugin] Server shutting down. Flushing cache.");
            FlushAllPlayerData();
        }

        private PlayerData LoadFromDisk(ulong steamId)
        {
            string path = Path.Combine(_dataPath, $"{steamId}.json");
            if (File.Exists(path))
            {
                string json = File.ReadAllText(path);
                return JsonConvert.DeserializeObject<PlayerData>(json)
                    ?? new PlayerData();
            }

            return new PlayerData();
        }

        private void SaveToDisk(ulong steamId, PlayerData data)
        {
            string path = Path.Combine(_dataPath, $"{steamId}.json");
            string json = JsonConvert.SerializeObject(data, Formatting.Indented);
            File.WriteAllText(path, json);
        }

        private void FlushAllPlayerData()
        {
            foreach (KeyValuePair<ulong, PlayerData> entry in _playerCache)
            {
                SaveToDisk(entry.Key, entry.Value);
            }

            _playerCache.Clear();
            Logger.Log("[PlayerDataPlugin] Flushed all player data to disk.");
        }
    }
}

InspectCacheCommand.cs

csharp
using Rocket.API;
using Rocket.Unturned.Chat;
using System.Collections.Generic;

namespace PlayerDataPlugin.Commands
{
    public class InspectCacheCommand : IRocketCommand
    {
        public string Name => "inspectcache";
        public string Help => "Displays cached player data for a specific player.";
        public string Syntax => "/inspectcache <player>";
        public List<string> Aliases => new List<string> { "cacheinfo" };
        public List<string> Permissions => new List<string> { "playerdata.inspectcache" };
        public AllowedCaller AllowedCaller => AllowedCaller.Console;

        public void Execute(IRocketPlayer caller, string[] command)
        {
            if (command.Length < 1)
            {
                UnturnedChat.Say(caller, "Usage: /inspectcache <player>");
                return;
            }

            UnturnedPlayer target = UnturnedPlayer.FromName(command[0]);
            if (target == null)
            {
                UnturnedChat.Say(caller, "Player not found.");
                return;
            }

            ulong steamId = target.CSteamID.m_SteamID;

            if (PlayerDataPlugin.Instance.TryGetPlayerData(steamId, out PlayerData data))
            {
                UnturnedChat.Say(caller,
                    $"Player: {target.DisplayName}\n" +
                    $"Currency: {data.CurrencyBalance}\n" +
                    $"Total playtime: {data.TotalPlaytimeSeconds}s\n" +
                    $"Last position: {data.LastPosition}");
            }
            else
            {
                UnturnedChat.Say(caller, "No cached data for this player.");
            }
        }
    }
}

Testing connection handlers

Single-player test

  1. Build the plugin and deploy it to the server's Rocket/Plugins/ directory.
  2. Start the server with a dedicated server executable.
  3. Connect from a Steam client on the same machine.
  4. Observe the Rocket.log output for the connection handler firing.
  5. Disconnect and verify the disconnect handler fires and the data file appears in the data directory.

Multi-player load test

  1. Start the server with the plugin deployed.
  2. Have two or more clients connect in rapid succession.
  3. Verify each client triggers OnPlayerConnected exactly once.
  4. Verify the player count in the handler matches the actual connected clients.
  5. Reload the plugin with /rocket reload PlayerDataPlugin while players are connected.
  6. Verify the handlers do not fire twice on the next connection.

Edge case test: mid-connection reload

  1. Connect a client and wait for OnPlayerConnected to fire.
  2. Before the player disconnects, run /rocket reload PlayerDataPlugin.
  3. Disconnect the player.
  4. Verify the disconnect handler fires exactly once.

Frequently asked questions

Is the player's IP address available in OnPlayerConnected?

Yes. The IP property on the UnturnedPlayer object is populated asynchronously by the engine, but it is available by the time OnPlayerConnected fires. You can read player.IP directly in the handler without additional waiting.

Can I cancel a player's connection after OnPlayerConnected has fired?

No. OnPlayerConnected is a notification-only event. To cancel a connection, use OnPlayerPreConnect, which fires before the player is added to the game world and returns a bool to allow or deny the connection.

Why does my disconnect handler not fire when the server crashes?

The disconnect handler only fires during a clean disconnect sequence. A server crash or power loss prevents the handler from running. Always use a startup-time cache cleanup in Load() to remove stale cache entries that were not flushed during the previous shutdown.

Can I send a chat message to a player in OnPlayerConnected?

Yes. The player's chat channel is open by the time OnPlayerConnected fires. Use player.SendChat() or UnturnedChat.Say(player, message) to send a welcome message or display rules.

Can I send a chat message to a player in OnPlayerDisconnected?

No. The player's connection is already closing when the disconnect handler runs. Any attempt to send a chat message will silently fail or throw an exception depending on the chat method used.

What happens if OnPlayerPreConnect throws an exception?

If the OnPlayerPreConnect handler throws an exception, RocketMod treats the connection as allowed by default. Always wrap cancellation logic in try/catch and log the error to diagnose pre-connect failures.

How do I detect whether a player is reconnecting after a crash versus a fresh join?

Track the player's last disconnect time in a persistent data file. On connect, read the file. If the disconnect time is within a configurable threshold (e.g., 30 seconds), treat it as a reconnect. Otherwise treat it as a fresh join.

Does OnPlayerConnected fire for the server host in a listen server?

No. The server host is not a connected client in the traditional sense. OnPlayerConnected only fires for remote clients who connect over the network.

Complete event reference

The following table lists every RocketMod event related to player connection lifecycle, including events from UnturnedPlayerEvents and UnturnedServer:

EventClassSignatureFires
OnPlayerPreConnectUnturnedPlayerEventsbool(UnturnedPlayer)Before player joins world. Return false to reject.
OnPlayerConnectedUnturnedPlayerEventsvoid(UnturnedPlayer)Player fully joined and interactive.
OnPlayerDisconnectedUnturnedPlayerEventsvoid(UnturnedPlayer)Player left (any reason).
OnServerShutdownUnturnedServervoid()Server shutting down cleanly.

Event order for a typical join-and-leave cycle

Cross-references

Document history

VersionDateAuthorNotes
1.02025-06-1857 StudiosInitial publication. Connection lifecycle, OnPlayerConnected/Disconnected/PreConnect coverage, caching architecture, full plugin walkthrough, testing methodology, common pitfalls.