How to make a Rust player stats plugin (K/D tracker)
A plugin that records kills, deaths, headshots and NPC kills per player in oxide/data, shows them with /stats [player], and optionally wipes the data on a new save. It uses OnEntityDeath to detect who killed whom and OnNewSave to reset on wipe.
Rust has no built-in way to look at a player's lifetime kill count, death count or K/D ratio, and the server console gives you nothing usable for comparing players at the end of a wipe. Admins who want leaderboards, wipe recaps or just a quick check on a suspected cheater have to build the numbers themselves.
This plugin listens to OnEntityDeath, works out whether the victim was a player or some other combat entity, and attributes the kill to info.InitiatorPlayer. Player victims increment the death counter and, when there is a valid killer with a different Steam ID, the killer's kill counter; headshots are counted separately and non-player kills go into their own bucket. Everything lives in a single data file keyed by Steam ID.
/stats prints your own numbers, /stats <name> looks up another player by name or ID, and a config switch empties all counters when a fresh save is created.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Player Stats Tracker", "RustPluginGenerator", "1.0.0")]
[Description("Tracks PvP kills, deaths, K/D, headshots and NPC kills per player.")]
public class PlayerStatsTracker : RustPlugin
{
private const string PermLookup = "playerstatstracker.lookup";
private const string DataFileName = "PlayerStatsTracker";
private PluginConfig config;
private StatsData data;
private Timer saveTimer;
private bool dirty;
private class PluginConfig
{
[JsonProperty("Reset stats on new save")]
public bool ResetOnNewSave { get; set; } = true;
[JsonProperty("Save interval in seconds")]
public float SaveInterval { get; set; } = 30f;
}
private class StoredPlayer
{
[JsonProperty("Name")]
public string Name { get; set; }
[JsonProperty("Kills")]
public long Kills { get; set; }
[JsonProperty("Deaths")]
public long Deaths { get; set; }
[JsonProperty("Headshot kills")]
public long HeadshotKills { get; set; }
[JsonProperty("NPC kills")]
public long NpcKills { get; set; }
}
private class StatsData
{
[JsonProperty("Players")]
public Dictionary<string, StoredPlayer> Players { get; set; } = new Dictionary<string, StoredPlayer>();
}
protected override void LoadDefaultConfig() => config = new PluginConfig();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<PluginConfig>();
if (config == null) throw new Exception();
}
catch
{
PrintWarning("Configuration file is invalid, using defaults");
LoadDefaultConfig();
}
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config);
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["OwnHeader"] = "Your stats:",
["Header"] = "Stats for {0}:",
["Kills"] = "Kills: {0}",
["Deaths"] = "Deaths: {0}",
["Kd"] = "K/D: {0}",
["Extra"] = "NPC kills: {0} | Headshot kills: {1}",
["NoStatsFor"] = "No stats recorded for {0}.",
["NoPlayer"] = "No player found matching '{0}'.",
["NoPermission"] = "You don't have permission to look up other players."
}, this);
}
private string Msg(string key, string userId = null, params object[] args) =>
string.Format(lang.GetMessage(key, this, userId), args);
private void Init()
{
permission.RegisterPermission(PermLookup, this);
}
private void OnServerInitialized()
{
data = Interface.Oxide.DataFileSystem.ReadObject<StatsData>(DataFileName) ?? new StatsData();
if (data.Players == null) data.Players = new Dictionary<string, StoredPlayer>();
}
private void Unload()
{
SaveData();
}
private void OnNewSave(string strFilename)
{
if (!config.ResetOnNewSave) return;
data = new StatsData();
SaveData();
}
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
if (entity == null) return;
var victim = entity as BasePlayer;
var killer = info?.InitiatorPlayer;
if (victim != null && !victim.IsNpc)
{
var victimRecord = GetOrCreate(victim);
victimRecord.Deaths++;
dirty = true;
if (killer != null && killer != victim && !killer.IsNpc)
{
var killerRecord = GetOrCreate(killer);
killerRecord.Kills++;
if (info.isHeadshot) killerRecord.HeadshotKills++;
dirty = true;
}
}
else if (killer != null && !killer.IsNpc)
{
var killerRecord = GetOrCreate(killer);
killerRecord.NpcKills++;
dirty = true;
}
if (dirty) ScheduleSave();
}
private StoredPlayer GetOrCreate(BasePlayer player)
{
var id = player.UserIDString;
if (!data.Players.TryGetValue(id, out var record))
{
record = new StoredPlayer { Name = player.displayName };
data.Players[id] = record;
}
if (!string.IsNullOrEmpty(player.displayName)) record.Name = player.displayName;
return record;
}
private void ScheduleSave()
{
if (saveTimer != null) return;
saveTimer = timer.Once(Mathf.Max(1f, config.SaveInterval), () =>
{
saveTimer = null;
SaveData();
});
}
private void SaveData()
{
if (saveTimer != null)
{
saveTimer.Destroy();
saveTimer = null;
}
if (data == null) return;
Interface.Oxide.DataFileSystem.WriteObject(DataFileName, data);
dirty = false;
}
[ChatCommand("stats")]
private void CmdStats(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (args == null || args.Length == 0)
{
ShowStats(player, player.UserIDString);
return;
}
if (!permission.UserHasPermission(player.UserIDString, PermLookup))
{
player.ChatMessage(Msg("NoPermission", player.UserIDString));
return;
}
var query = string.Join(" ", args).Trim();
var target = BasePlayer.FindAwakeOrSleeping(query);
string targetId = null;
string targetName = query;
if (target != null)
{
targetId = target.UserIDString;
targetName = target.displayName;
}
else if (ulong.TryParse(query, out var parsedId))
{
targetId = parsedId.ToString();
if (data.Players.TryGetValue(targetId, out var stored))
{
targetName = stored.Name;
}
}
if (targetId == null || !data.Players.ContainsKey(targetId))
{
player.ChatMessage(Msg("NoPlayer", player.UserIDString, query));
return;
}
ShowStats(player, targetId, targetName);
}
private void ShowStats(BasePlayer viewer, string targetId, string targetName = null)
{
if (!data.Players.TryGetValue(targetId, out var record))
{
viewer.ChatMessage(Msg("NoStatsFor", viewer.UserIDString, targetName ?? targetId));
return;
}
var name = string.IsNullOrEmpty(record.Name) ? (targetName ?? targetId) : record.Name;
var kd = record.Deaths == 0 ? record.Kills : (double)record.Kills / record.Deaths;
if (targetId == viewer.UserIDString)
{
viewer.ChatMessage(Msg("OwnHeader", viewer.UserIDString));
}
else
{
viewer.ChatMessage(Msg("Header", viewer.UserIDString, name));
}
viewer.ChatMessage(Msg("Kills", viewer.UserIDString, record.Kills));
viewer.ChatMessage(Msg("Deaths", viewer.UserIDString, record.Deaths));
viewer.ChatMessage(Msg("Kd", viewer.UserIDString, kd.ToString("0.00")));
viewer.ChatMessage(Msg("Extra", viewer.UserIDString, record.NpcKills, record.HeadshotKills));
}
}
}Hooks used: OnServerInitialized, OnNewSave, OnEntityDeath
Config file
Created automatically on first load at oxide/config/PlayerStatsTracker.json.
{
"Reset stats on new save": true,
"Save interval in seconds": 30
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/stats | Show your own kills, deaths, K/D, headshots and NPC kills. | Everyone |
/stats <name or steamid> | Look up another player's stats by partial name or Steam ID. | playerstatstracker.lookup |
| Permission | Grants |
|---|---|
playerstatstracker.lookup | Allows /stats <name|steamid> to view another player's stats. |
How the code works
Data model and storage
A
StoredPlayerclass holdsName,Kills,Deaths,HeadshotKillsandNpcKillsas[JsonProperty]fields withlongcounters so they never overflow in practice. The rootStatsDataonly contains aDictionary<string, StoredPlayer> Playerskeyed byUserIDString.All reads and writes go through
Interface.Oxide.DataFileSystem.ReadObject<StatsData>(DataFileName)andWriteObject. The file is loaded inOnServerInitialized, where a null result is replaced with a freshStatsDataand a null dictionary is also repaired, so the rest of the code can assumedata.Playersexists.Detecting the kill
OnEntityDeath(BaseCombatEntity entity, HitInfo info)is called for every death. The plugin returns immediately whenentityis null. The victim is treated as a player only whenentity as BasePlayeris non-null andIsNpcis false.The killer is read from
info?.InitiatorPlayer, so a nullinfoor a death without an initiator (world damage, fire, fall, decay) credits nobody. A player who kills themselves (killer == victim) is only counted as a death.Counting non-player kills and headshots
Every other
BaseCombatEntitydeath falls into theelsebranch. If the killer exists and is not an NPC, the killer'sNpcKillscounter is incremented. This covers scientists, animals and anything else that is not aBasePlayerwithout the plugin needing to know the specific type.For PvP kills,
info.isHeadshotdecides whether the killer'sHeadshotKillscounter is bumped.GetOrCreaterefreshes the storedNamefromplayer.displayNameon every touch, so renamed players stay accurate.Saving without hammering the disk
Writing the data file on every death is unnecessary and risky on busy servers. Instead the plugin sets a
dirtyflag and callsScheduleSave, which starts a singletimer.Once(Mathf.Max(1f, config.SaveInterval), ...)and stores it insaveTimer. While the timer exists, later kills only modify the in-memory dictionary.When it fires,
SaveData()writes the file, clearssaveTimerand resetsdirty.UnloadcallsSaveData()directly so no changes are lost on reload.The /stats command
[ChatCommand("stats")]takes the player, the command andargs. With no arguments the plugin callsShowStatswith the caller's own ID. With arguments it joins them so names with spaces work and looks up the target withBasePlayer.FindAwakeOrSleepingfirst, falling back to a Steam ID lookup withulong.TryParse.Looking up someone else requires
playerstatstracker.lookup, checked withpermission.UserHasPermission. If no online player and no matching record is found, the caller gets theNoPlayermessage.Showing the numbers
ShowStatsreads the record fromdata.Playersand sends five lines throughlang: kills, deaths, K/D, then a final line with NPC kills and headshot kills. The header isOwnHeaderwhen the viewer is looking at themselves andHeaderwith the name otherwise.K/D is computed as
Kills / Deathsand formatted withToString("0.00"), with a guard so zero deaths returns the raw kill count instead of dividing. A player with no record gets theNoStatsFormessage.Resetting on wipe
OnNewSave(string strFilename)fires when the map is regenerated. IfReset on new saveis true inoxide/config/PlayerStatsTracker.json, the plugin replacesdatawith a newStatsData()and callsSaveData()immediately so the wipe starts clean.Set the key to
falseto keep lifetime stats across wipes; the Steam ID keys stay stable, so totals remain accurate.
Install it on your server
Save PlayerStatsTracker.cs
Keep the file name exactly as the class name. Oxide matches them.Upload it to the plugins folder
Oxide:oxide/plugins/. Carbon:carbon/plugins/. The server compiles and loads it within a few seconds, no restart needed.Edit the config if you want
On first load the default config is written tooxide/config/PlayerStatsTracker.json(Carbon:carbon/configs/PlayerStatsTracker.json). After editing, reload it:oxide.reload PlayerStatsTracker c.reload PlayerStatsTracker # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default playerstatstracker.lookup c.grant group default playerstatstracker.lookup # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Leaderboard command
Add /top that prints the ten players with the highest K/D ratio.
Generate this versionReward per kill
Hand out scrap when a player gets a PvP kill.
Generate this versionWipe-day broadcast
Announce the top killer when the server saves a new map.
Generate this version
FAQ
Are NPC kills counted as PvP kills?
No. When the victim is not a BasePlayer, the plugin only increments the killer's NpcKills counter. Scientists, animals and any other combat entity follow the same path, so Kills and Deaths stay PvP-only.
Does a suicide or fall death count as a death?
Yes. A BasePlayer victim always gets a death. The kill is only credited when info.InitiatorPlayer exists and is not the victim. World damage, bleeding and fall damage have no initiator, so they add deaths but no kills.
How often is the data file written?
Not on every death. The first change after a save sets a dirty flag and starts a single timer with Save interval in seconds. Further kills inside that window just modify the in-memory dictionary, and the timer writes once. Reloading or unloading the plugin writes immediately.
Can I keep stats between wipes?
Set Reset stats on new save to false in oxide/config/PlayerStatsTracker.json. OnNewSave will then skip the reset, and the data file survives map regeneration. Steam IDs stay the same, so lifetime stats remain accurate.
How do I install it on Oxide or Carbon?
Drop PlayerStatsTracker.cs into oxide/plugins and wait for it to compile; Carbon loads the same file from its plugin folder. Grant the lookup permission to staff with oxide.grant user <steamid> playerstatstracker.lookup.
Customize it for your server
Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More stats plugins
- Death cause statsThis plugin records every player death, classifies the cause from the HitInfo passed to OnPlayerDeath, and stores running totals per player soโฆOnServerInitializedOnPlayerDeath
- Gather statisticsTracks wood, stone, metal ore, sulfur ore and cloth gathered per player for the current wipe, persists the totals in oxide/data, and shows them withโฆOnServerInitializedOnDispenserGatheredOnCollectiblePickup
- Last seen commandRecords a timestamp for every player on disconnect and answers /seen <name> with a relative time such as '2 hours ago' or 'online now'.OnServerInitializedOnPlayerConnectedOnPlayerDisconnected
Last updated Oct 26, 2026.
Customize player stats
Customize