Join Validation and Connection Filtering
Every Unturned server operator eventually needs to control who can join the server. A whitelist for private communities, a Steam account age gate to block brand-new accounts, or a custom validation rule that integrates with your community's forum or Discord bot. RocketMod provides the OnJoinRequested event on UnturnedPermissions that fires when a player attempts to connect, giving your plugin the chance to accept or reject the connection before the player fully joins.
This article covers the join validation pipeline, the OnJoinRequested event handler pattern, and several common validation patterns you can implement in your RocketMod plugins.
Prerequisites
- Articles 1 through 7 (Fundamentals track), plus article 28 (Kick, Ban, and Admin Commands) for ban management background.
- Familiarity with Steam Web API for external validation checks.
- A server token from the Steamworks partner site if you plan to make Steam Web API calls.
What you'll learn
- How to subscribe to
OnJoinRequestedand use itsUnturnedPlayerparameter. - How to use the return value to accept or reject a connection.
- How to build a whitelist system that checks against a local file.
- How to use Steam Web API to check account age and VAC status.
- How to handle the timing of join validation -- what happens when validation takes too long.
The OnJoinRequested event
The OnJoinRequested event lives on the UnturnedPermissions class in the Rocket.Unturned.Permissions namespace. It fires when a player's connection request reaches the RocketMod layer, after Steam authentication has completed but before the player's character spawns in the world.
Subscribing and unsubscribing
Subscribe in Load() and desubscribe in Unload():
csharp
protected override void Load()
{
UnturnedPermissions.OnJoinRequested += OnPlayerJoinRequested;
}
protected override void Unload()
{
UnturnedPermissions.OnJoinRequested -= OnPlayerJoinRequested;
}The handler receives the joining player's UnturnedPlayer object. At this point the player has authenticated with Steam and their basic Steam identity data is available -- Steam ID, display name, and character appearance.
Return value and connection control
Your handler returns a bool:
- Return
trueto allow the player to join. - Return
falseto reject the player.
A common beginner mistake is to forget the return value. If the handler does not return anything, the method implicitly returns false -- which means the player is rejected. Always return true at the end of your handler if validation passes.
Here is the correct handler pattern:
csharp
private bool OnPlayerJoinRequested(UnturnedPlayer player)
{
// Perform validation checks
if (!IsPlayerAllowed(player))
{
return false;
}
return true;
}Always return true at the end of your handler
If your validation handler does not return true at the end, the method implicitly returns false and every player will be rejected. The return value is the gate -- true opens it, false closes it.
What happens when you return false
When the handler returns false, the player sees a generic "You have been disconnected from the server" message on their client. RocketMod does not expose a way to customize this disconnect message through the OnJoinRequested return value -- the player sees the default Unturned disconnect screen.
Whitelist implementation
A whitelist is the most common join validation pattern. The whitelist is a list of Steam64 IDs that are allowed to join. Everyone else is rejected.
File-based whitelist
Store the whitelist in a text file, one Steam64 ID per line:
76561197960265728
76561197960265729
76561197960265730Read the whitelist into a HashSet<CSteamID> on plugin load:
csharp
using System.Collections.Generic;
using System.IO;
using Steamworks;
public class WhitelistManager
{
private HashSet<CSteamID> _whitelist = new HashSet<CSteamID>();
private string _filePath;
public WhitelistManager(string filePath)
{
_filePath = filePath;
LoadWhitelist();
}
public void LoadWhitelist()
{
_whitelist.Clear();
if (!File.Exists(_filePath))
{
Rocket.Core.Logging.Logger.Log("Whitelist file not found. No players whitelisted.");
return;
}
string[] lines = File.ReadAllLines(_filePath);
foreach (string line in lines)
{
string trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#"))
continue;
if (ulong.TryParse(trimmed, out ulong steam64))
{
_whitelist.Add(new CSteamID(steam64));
}
else
{
Rocket.Core.Logging.Logger.Log($"Invalid Steam64 ID in whitelist: {trimmed}");
}
}
Rocket.Core.Logging.Logger.Log($"Loaded {_whitelist.Count} whitelist entries.");
}
public bool IsWhitelisted(CSteamID steamId)
{
return _whitelist.Contains(steamId);
}
}Whitelist join validation handler
csharp
private WhitelistManager _whitelist;
protected override void Load()
{
string whitelistPath = Path.Combine(Directory.GetCurrentDirectory(), "Plugins", "JoinValidator", "whitelist.txt");
_whitelist = new WhitelistManager(whitelistPath);
UnturnedPermissions.OnJoinRequested += OnPlayerJoinRequested;
}
protected override void Unload()
{
UnturnedPermissions.OnJoinRequested -= OnPlayerJoinRequested;
}
private bool OnPlayerJoinRequested(UnturnedPlayer player)
{
if (!_whitelist.IsWhitelisted(player.CSteamID))
{
Rocket.Core.Logging.Logger.Log($"{player.DisplayName} ({player.CSteamID}) was rejected by whitelist.");
return false;
}
return true;
}Steam account age gate
Some server operators want to block brand-new Steam accounts to prevent ban evaders and alt accounts. You can check account age using Steam's game server API or the Steam Web API. The Web API is more reliable for cross-server checks and does not require the plugin to run on the server's Steam client.
Steam Web API account age check
The Steam Web API endpoint GetPlayerSummaries returns account creation date. You need a Steam Web API key to call this endpoint.
csharp
using System;
using System.Net.Http;
using System.Threading.Tasks;
public class SteamAccountChecker
{
private readonly string _apiKey;
private readonly HttpClient _httpClient;
public SteamAccountChecker(string apiKey)
{
_apiKey = apiKey;
_httpClient = new HttpClient();
}
public async Task<int> GetAccountAgeInDays(ulong steam64)
{
string url = $"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={_apiKey}&steamids={steam64}";
string response = await _httpClient.GetStringAsync(url);
int timestampIndex = response.IndexOf("\"timecreated\"");
if (timestampIndex < 0)
return -1;
int valueStart = response.IndexOf(':', timestampIndex) + 1;
int valueEnd = response.IndexOfAny(new[] { ',', '}' }, valueStart);
string tsString = response.Substring(valueStart, valueEnd - valueStart).Trim();
if (!long.TryParse(tsString, out long unixTimestamp))
return -1;
DateTime accountCreated = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).DateTime;
return (DateTime.UtcNow - accountCreated).Days;
}
}The Web API call is asynchronous, but OnJoinRequested is synchronous. You need to handle the async wait carefully. One approach is to pre-fetch account data when the player is first seen in the join queue, but the current event does not provide a pre-join identifier. An alternative is to accept the player first and kick them if validation fails after the async check -- though this is less clean than rejecting at join time.
A simpler synchronous approach uses the Steam game server API:
csharp
using Steamworks;
public uint GetAccountCreationTimestamp(ulong steam64)
{
CSteamID steamId = new CSteamID(steam64);
uint creationTime = SteamGameServerNetworking.GetAccountCreationTimestamp(steamId);
return creationTime;
}This call is synchronous and runs on the server's Steam game server instance. It requires the server to be running with Steam Game Server authentication enabled (pass -Secure in the launch options).
csharp
private bool OnPlayerJoinRequested(UnturnedPlayer player)
{
uint minAccountAgeDays = Configuration.Instance.MinAccountAgeDays;
if (minAccountAgeDays > 0)
{
uint creationTime = SteamGameServerNetworking.GetAccountCreationTimestamp(player.CSteamID);
if (creationTime == 0)
{
Rocket.Core.Logging.Logger.Log($"Could not retrieve account creation date for {player.CSteamID}.");
return true;
}
DateTime accountCreated = DateTimeOffset.FromUnixTimeSeconds(creationTime).DateTime;
int ageDays = (DateTime.UtcNow - accountCreated).Days;
if (ageDays < minAccountAgeDays)
{
Rocket.Core.Logging.Logger.Log($"{player.DisplayName} rejected: account age {ageDays}d < {minAccountAgeDays}d.");
return false;
}
}
return true;
}VAC and game ban check
The Steam Web API also provides VAC and game ban status through GetPlayerBans. This requires the same Web API key approach.
csharp
public class SteamBanChecker
{
private readonly string _apiKey;
private readonly HttpClient _httpClient;
public SteamBanChecker(string apiKey)
{
_apiKey = apiKey;
_httpClient = new HttpClient();
}
public async Task<bool> HasActiveVACBan(ulong steam64)
{
string url = $"https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key={_apiKey}&steamids={steam64}";
string response = await _httpClient.GetStringAsync(url);
bool hasVAC = response.Contains("\"NumberOfVACBans\"") && !response.Contains("\"NumberOfVACBans\": 0");
return hasVAC;
}
}As with the account age check, the Web API approach requires handling the async-to-sync transition.
Connection timing considerations
The OnJoinRequested event fires during the connection handshake. The player's client is waiting for a response. If your validation logic takes too long, the client may time out and disconnect on its own.
Timeout thresholds
- Unturned's client-side connection timeout is approximately 15 seconds.
- If your validation logic (e.g., a Steam Web API call) takes more than 10 seconds, the client may disconnect before your handler returns.
- Synchronous operations like file reads are negligible in duration (sub-millisecond).
What not to do in OnJoinRequested
Avoid the following in your join validation handler:
- HTTP calls without a fast timeout. If you make a Web API call, set a timeout of 5 seconds or less.
- Database queries on slow connections. If your database server is remote, a query that takes 2 seconds can accumulate and block the join queue.
- File writes. Writing to disk during the join handshake is unnecessary and adds latency.
Thread.Sleep()or any blocking wait. The event handler runs on the main Unity thread. Blocking it freezes all server processing.
Slow validation strategy
If your validation logic is inherently slow (e.g., a database query), consider caching validation results between sessions or pre-calculating validation decisions on previous disconnect. Long-running operations in the join handler will block all other players from connecting.
Compound validation pipeline
A realistic join validation plugin combines multiple checks. The following pattern chains them in a logical order -- fastest and cheapest checks first:
csharp
private bool OnPlayerJoinRequested(UnturnedPlayer player)
{
// 1. Check whitelist (file read, fast)
if (!_whitelist.IsWhitelisted(player.CSteamID))
{
Rocket.Core.Logging.Logger.Log($"{player.DisplayName} ({player.CSteamID}) rejected by whitelist.");
return false;
}
// 2. Account age gate (synchronous Steam API, moderately fast)
uint minDays = Configuration.Instance.MinAccountAgeDays;
if (minDays > 0)
{
uint creationTime = SteamGameServerNetworking.GetAccountCreationTimestamp(player.CSteamID);
if (creationTime > 0)
{
DateTime accountCreated = DateTimeOffset.FromUnixTimeSeconds(creationTime).DateTime;
int ageDays = (DateTime.UtcNow - accountCreated).Days;
if (ageDays < minDays)
{
Rocket.Core.Logging.Logger.Log($"{player.DisplayName} rejected: account age {ageDays}d < {minDays}d.");
return false;
}
}
}
// All checks passed
Rocket.Core.Logging.Logger.Log($"{player.DisplayName} ({player.CSteamID}) passed join validation.");
return true;
}The pipeline orders checks from fastest (local whitelist, milliseconds) to slowest (account age gate, tens of milliseconds). If an early check rejects, the later checks never run.
Edge cases
Join validation for RocketMod console
The OnJoinRequested event only fires for remote player connections. It does not fire for the server console or for LAN connections that bypass the Steam authentication layer. Your validation plugin cannot reject the server owner's local connection.
Concurrent connections
If multiple players join simultaneously, each triggers a separate OnJoinRequested event. The events are handled synchronously on the main thread, so validation for player B waits for player A's handler to complete. Keep each handler short to avoid serializing joins.
Server full condition
Unturned has a built-in server full check. If the server is full, the connection is rejected before OnJoinRequested fires. Your plugin does not need to handle the server-full case.
Frequently asked questions
Can I use OnJoinRequested to manage join cooldowns?
Yes. You can track the last disconnect time for each player and reject reconnections within a cooldown period. Store the disconnect timestamp in a dictionary keyed by Steam64 ID from the OnPlayerDisconnected event on UnturnedEvents.
What if my validation handler throws an exception?
An unhandled exception in OnJoinRequested crashes the handler and the player is neither accepted nor rejected deterministically. Wrap your handler body in a try-catch that logs the error and returns true to avoid blocking legitimate players.
Does OnJoinRequested work with RocketMod's fake IP mode?
The event fires regardless of the fake IP configuration. The UnturnedPlayer object contains the real Steam identity data, not the fake IP data.
Cross-references
- Kick, Ban, and Admin Commands -- the previous article; ban list management.
- Skill Management -- the next article; managing player skills on join.
- Event Subscription and Lifecycle -- the event subscription pattern used by OnJoinRequested.
- Understanding the Permission System -- permission-based pattern reference.
What changed in this revision
- Corrected
OnJoinRequestedowning type fromUnturnedPlayerEventstoUnturnedPermissions(the event is atRocket.Unturned/Permissions/UnturnedPermissions.cs:1226). - Removed the "Custom rejection messages" section --
Provider.reject()is an SDG.Unturned method not present in the RocketMod API surface. - Removed the "Ban list check at join time" section --
SteamBlacklist.IsBanned()is an SDG.Unturned method not present in the RocketMod API surface. - Removed the "Permissions bypass" edge-case section --
R.Permissionsis not a property on theRclass in the RocketMod API surface. - Removed
Provider.reject()calls from the compound validation pipeline and FAQ; replaced with log-and-return-false patterns. - Removed
Provider.reject()reference from the "What happens when you return false" section; replaced with a note that RocketMod does not expose a disconnect-message customization surface. - Removed the "Can I call SteamBlacklist.IsBanned from OnJoinRequested?" FAQ entry -- the method is not in the RocketMod API surface.
- Corrected
OnPlayerDisconnectedevent reference in FAQ to point toUnturnedEvents(the event lives onUnturnedEvents, notUnturnedPlayerEvents). - Removed the explicit
delegate void JoinRequestedsignature claim -- delegate type is unverifiable from the API surface. - Removed the "Queued join processing" subsection that suggested using
Provider.reject()for slow validations.
