Fix "KeyNotFoundException: The given key was not present in the dictionary" in Oxide plugins
A hook tried to read a dictionary entry with an indexer for a key that was never added. For player-tracking plugins this usually means the player connected before the plugin loaded. Use TryGetValue or add the key on join.
Failed to call hook 'OnPlayerDisconnected' on plugin 'PlaytimeTracker v1.0.0' (KeyNotFoundException: The given key was not present in the dictionary.) at System.Collections.Generic.Dictionary`2[TKey,TValue].get_Item (TKey key) [0x0001e] in <b0e1ad7573a24fd5a9f2af9595e677e7>:0 at Oxide.Plugins.PlaytimeTracker.OnPlayerDisconnected (BasePlayer player, System.String reason) [0x00012] in <8f3c1a2b4d5e4f6a9b0c1d2e3f4a5b6c>:0
Why it happens
Dictionary<TKey, TValue> throws KeyNotFoundException when you access dict[key] and the key is absent. In OnPlayerDisconnected the key is typically a player ID string. Players who were already connected when you called oxide.reload (or when the server started with the plugin late) never fired OnPlayerConnected, so their ID was never written into the dictionary. The same applies to data loaded from disk that predates the current session, to keys removed earlier in a disconnect handler that ran twice, and to code that mixes player.UserIDString with a ulong key type so the lookup never matches. Oxide keeps the plugin loaded after this exception and keeps calling the hook, so you will see the error every time that player disconnects.
How to fix it
- Replace every
dict[key]read on player-driven paths withTryGetValueand handle the missing case. - Seed the entry in
OnPlayerConnected(and inOnServerInitializedfor already-connected players) with a zero default. - Make the key type consistent: pick
player.UserIDStringorplayer.userID.Get(), not a mix. - Keep removal idempotent with
Remove(key), which returnsfalseif the key is absent instead of throwing. - Make sure no other dictionary write in another hook stores a different key format, because that creates the same mismatch later.
- Reload with
oxide.reload PlaytimeTracker, join the server, then disconnect. The console should printUnloaded plugin PlaytimeTracker v1.0.1andLoaded plugin PlaytimeTracker v1.0.1, and noFailed to call hook 'OnPlayerDisconnected'line should appear.
Before and after
using System.Collections.Generic;
using Oxide.Core;
using Oxide.Core.Plugins;
namespace Oxide.Plugins
{
[Info("PlaytimeTracker", "RustPluginGenerator", "1.0.0")]
[Description("Tracks how long each player has been connected.")]
public class PlaytimeTracker : RustPlugin
{
private Dictionary<string, double> joinTimes = new Dictionary<string, double>();
private void OnServerInitialized()
{
joinTimes = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, double>>("PlaytimeTracker") ?? new Dictionary<string, double>();
}
private void OnPlayerConnected(BasePlayer player)
{
if (player == null) return;
joinTimes[player.UserIDString] = UnityEngine.Time.realtimeSinceStartup;
Interface.Oxide.DataFileSystem.WriteObject("PlaytimeTracker", joinTimes);
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
if (player == null) return;
double joined = joinTimes[player.UserIDString];
double seconds = UnityEngine.Time.realtimeSinceStartup - joined;
Puts(player.displayName + " played " + (int)seconds + "s");
joinTimes.Remove(player.UserIDString);
Interface.Oxide.DataFileSystem.WriteObject("PlaytimeTracker", joinTimes);
}
}
}using UnityEngine;
using System.Collections.Generic;
using Oxide.Core;
using Oxide.Core.Plugins;
namespace Oxide.Plugins
{
[Info("PlaytimeTracker", "RustPluginGenerator", "1.0.1")]
[Description("Tracks how long each player has been connected.")]
public class PlaytimeTracker : RustPlugin
{
private Dictionary<string, double> joinTimes = new Dictionary<string, double>();
private void OnServerInitialized()
{
joinTimes = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, double>>("PlaytimeTracker") ?? new Dictionary<string, double>();
// Players already online never fired OnPlayerConnected.
foreach (var player in BasePlayer.activePlayerList)
{
if (player == null) continue;
joinTimes[player.UserIDString] = UnityEngine.Time.realtimeSinceStartup;
}
}
private void OnPlayerConnected(BasePlayer player)
{
if (player == null || string.IsNullOrEmpty(player.UserIDString)) return;
joinTimes[player.UserIDString] = UnityEngine.Time.realtimeSinceStartup;
Interface.Oxide.DataFileSystem.WriteObject("PlaytimeTracker", joinTimes);
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
if (player == null || string.IsNullOrEmpty(player.UserIDString)) return;
double joined;
if (!joinTimes.TryGetValue(player.UserIDString, out joined))
{
// No start time recorded (connected before load). Start counting now and skip.
joinTimes[player.UserIDString] = UnityEngine.Time.realtimeSinceStartup;
return;
}
double seconds = UnityEngine.Time.realtimeSinceStartup - joined;
Puts(player.displayName + " played " + (int)seconds + "s");
joinTimes.Remove(player.UserIDString);
Interface.Oxide.DataFileSystem.WriteObject("PlaytimeTracker", joinTimes);
}
}
}FAQ
Why does the error only happen for some players?
Those players connected before the plugin was loaded or reloaded, so OnPlayerConnected never ran for them and their entry is missing from the dictionary. Players who join after the reload are tracked normally.
Does the exception unload the plugin?
No. Oxide logs Failed to call hook ... with a stack trace, keeps the plugin loaded and calls the hook again next time. That is why you see repeated lines instead of one compile or load failure.
Should I use UserIDString or the ulong userID as the key?
Either works as long as you are consistent. UserIDString is convenient because the permission and lang APIs take it; player.userID.Get() avoids string allocation. Never write with one and read with the other.
Different error?
Paste the full console output and get the cause and a fix in plain English.
Open the error explainerSkip the debugging
Generated plugins are compiled before you get them, and compiler errors are fixed automatically.
Generate a plugin2 free plugins a month, no card.
Last updated Oct 26, 2026.
Get plugins that compile first time
Generate