How to make a Rust top players command plugin (stat leaderboard)
Tracks kills, deaths and playtime per player and prints a top 10 leaderboard in chat through /top kills|deaths|playtime. Uses OnEntityDeath and OnPlayerDisconnected.
Most stats plugins pull numbers from another plugin or a database, which makes the /top command fragile. This plugin keeps its own counters, so the leaderboard works on a fresh server with no dependencies.
Kills and deaths are counted in OnEntityDeath: when the victim is a BasePlayer and the killer (via info.InitiatorPlayer) is a real, non-NPC player, the killer gets a kill and the victim gets a death. Playtime is accumulated with a timer.Every loop that adds 60 seconds to every connected, non-NPC player each minute, so it is close to real on-server time and not inflated by name changes or reconnects.
/top kills, /top deaths and /top playtime sort the stored dictionary and print the winner list in chat. Stats are written to oxide/data so they survive restarts and wipes of the plugin, and an admin-only reset command clears the data when a wipe loop starts.
The plugin code
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Top Players Command", "RustPluginGenerator", "1.0.0")]
[Description("Tracks kills, deaths and playtime and prints a top 10 leaderboard through /top.")]
public class TopPlayersCommand : RustPlugin
{
private const string PermReset = "topplayerscommand.reset";
private PluginConfig config;
private StoredData data;
private Timer playtimeTimer;
private Timer saveTimer;
private class PlayerStats
{
public string Name;
public int Kills;
public int Deaths;
public long Seconds;
}
private class StoredData
{
public Dictionary<ulong, PlayerStats> Players = new Dictionary<ulong, PlayerStats>();
}
private class PluginConfig
{
[JsonProperty("Players shown in leaderboard")]
public int DisplayCount = 10;
[JsonProperty("Save interval (seconds)")]
public int SaveIntervalSeconds = 300;
}
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>
{
["TopHeader"] = "Top {0} (page 1):",
["TopRow"] = "{0}. {1} - {2}",
["Empty"] = "No players tracked yet for {0}.",
["Usage"] = "Usage: /top kills | /top deaths | /top playtime",
["ResetDone"] = "All tracked stats have been cleared."
}, 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(PermReset, this);
}
private void OnServerInitialized()
{
data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>("TopPlayersCommand") ?? new StoredData();
if (data.Players == null) data.Players = new Dictionary<ulong, PlayerStats>();
playtimeTimer = timer.Every(60f, TrackPlaytime);
saveTimer = timer.Every(Mathf.Max(60, config.SaveIntervalSeconds), SaveData);
}
private void Unload()
{
if (playtimeTimer != null) playtimeTimer.Destroy();
if (saveTimer != null) saveTimer.Destroy();
SaveData();
data = null;
}
private void SaveData()
{
if (data == null) return;
Interface.Oxide.DataFileSystem.WriteObject("TopPlayersCommand", data);
}
private PlayerStats GetOrCreate(ulong userId, string name)
{
PlayerStats stats;
if (!data.Players.TryGetValue(userId, out stats))
{
stats = new PlayerStats { Name = name ?? "Unknown" };
data.Players[userId] = stats;
}
else if (!string.IsNullOrEmpty(name))
{
stats.Name = name;
}
return stats;
}
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
var victim = entity as BasePlayer;
if (victim == null || victim.IsNpc || info == null) return;
var attacker = info.InitiatorPlayer;
if (attacker == null || attacker.IsNpc) return;
var victimId = victim.userID.Get();
var attackerId = attacker.userID.Get();
GetOrCreate(victimId, victim.displayName).Deaths++;
if (attackerId != victimId)
{
GetOrCreate(attackerId, attacker.displayName).Kills++;
}
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
if (player == null || data == null) return;
PlayerStats stats;
if (data.Players.TryGetValue(player.userID.Get(), out stats))
{
stats.Name = player.displayName;
}
}
private void TrackPlaytime()
{
if (data == null) return;
foreach (var player in BasePlayer.activePlayerList)
{
if (player == null || player.IsNpc || !player.IsConnected) continue;
GetOrCreate(player.userID.Get(), player.displayName).Seconds += 60;
}
}
[ChatCommand("top")]
private void CmdTop(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (args == null || args.Length == 0)
{
SendReply(player, Msg("Usage", player.UserIDString));
return;
}
var key = args[0].ToLowerInvariant();
Func<PlayerStats, long> selector;
string label;
switch (key)
{
case "kills":
selector = s => s.Kills;
label = "Kills";
break;
case "deaths":
selector = s => s.Deaths;
label = "Deaths";
break;
case "playtime":
selector = s => s.Seconds;
label = "Playtime";
break;
default:
SendReply(player, Msg("Usage", player.UserIDString));
return;
}
var top = data.Players.Values
.OrderByDescending(selector)
.ThenBy(s => s.Name)
.Take(Mathf.Max(1, config.DisplayCount))
.ToList();
if (top.Count == 0 || selector(top[0]) <= 0)
{
SendReply(player, Msg("Empty", player.UserIDString, label));
return;
}
SendReply(player, Msg("TopHeader", player.UserIDString, label));
for (int i = 0; i < top.Count; i++)
{
var entry = top[i];
var value = key == "playtime" ? FormatTime(entry.Seconds) : selector(entry).ToString();
SendReply(player, Msg("TopRow", player.UserIDString, i + 1, entry.Name, value));
}
}
[ConsoleCommand("topplayers.reset")]
private void CcmdReset(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (player != null && !permission.UserHasPermission(player.UserIDString, PermReset))
{
arg.ReplyWith("You do not have permission to use this command.");
return;
}
if (arg.GetString(0, "") != "confirm")
{
arg.ReplyWith("Run topplayers.reset confirm to clear all stored stats.");
return;
}
data.Players.Clear();
SaveData();
arg.ReplyWith(Msg("ResetDone"));
}
private static string FormatTime(long seconds)
{
var span = TimeSpan.FromSeconds(seconds);
return string.Format("{0}h {1}m", (int)span.TotalHours, span.Minutes);
}
}
}Hooks used: OnServerInitialized, OnEntityDeath, OnPlayerDisconnected
Config file
Created automatically on first load at oxide/config/TopPlayersCommand.json.
{
"Players shown in leaderboard": 10,
"Save interval (seconds)": 300
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/top kills | Prints the top 10 players by kills. | Everyone |
/top deaths | Prints the top 10 players by deaths. | Everyone |
/top playtime | Prints the top 10 players by tracked playtime. | Everyone |
topplayers.reset | Clears all tracked stats after a confirmation argument. | topplayerscommand.reset |
| Permission | Grants |
|---|---|
topplayerscommand.reset | Allows running the topplayers.reset console command that wipes stored stats. |
How the code works
Storing stats per player
A
PlayerStatsclass holdsName,Kills,DeathsandSeconds. Everything lives in aDictionary<ulong, PlayerStats>inside aStoredDatawrapper, saved withInterface.Oxide.DataFileSystem.WriteObject("TopPlayersCommand", data)inUnloadand every five minutes. TheLoadDefaultConfig/LoadConfigpair setsSaveIntervalSecondsandDisplayCountfrom the config file, so admins can change how many rows/topprints without touching code.Counting kills and deaths
OnEntityDeath(BaseCombatEntity entity, HitInfo info)runs for every death. Bail out whenentityis not aBasePlayerand wheninfoorinfo.InitiatorPlayeris null. Skip NPCs withIsNpc. Get or create both records, incrementKillson the killer andDeathson the victim, and do nothing when killer and victim are the same player (suicide). Every value written here is also updated invictim.displayNameso the leaderboard shows current names.Accumulating playtime
A
timer.Every(60f, ...)loop iteratesBasePlayer.activePlayerList, skips NPCs and sleeping or disconnected players, and adds 60 to each record'sSeconds. The timer reference is kept in aTimerfield and destroyed inUnload. Because the loop reads live players, it never counts time spent offline, and a reconnecting player keeps the total already stored.Rendering the leaderboard
/topis registered with[ChatCommand("top")]. It takesargs[0], mapskills,deathsandplaytimeto a selector, orders the dictionary values descending withOrderByDescending(...)and takesconfig.DisplayCountentries. Each row is formatted withMsg("TopRow", ...), a language key that takes rank, name and a formatted value; playtime is printed asXh Ymusing a smallFormatTimehelper.Saving on disconnect and unload
OnPlayerDisconnected(BasePlayer player, string reason)refreshes the stored display name fromplayer.displayNameso/topstays accurate after a rename.Unloaddestroys the playtime timer, writes the data file once and clears the dictionary. Neither path sends chat messages, so a busy restart does not spam the console.Resetting between wipes
[ConsoleCommand("topplayers.reset")]requires thetopplayerscommand.resetpermission and the literal argumentconfirm. It clears the dictionary, writes the empty data file and replies witharg.ReplyWith(...).arg.Player()is null from RCON or the server console, so the permission check only runs when a player executes it. Use it after each wipe so old names never appear in the list.
Install it on your server
Save TopPlayersCommand.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/TopPlayersCommand.json(Carbon:carbon/configs/TopPlayersCommand.json). After editing, reload it:oxide.reload TopPlayersCommand c.reload TopPlayersCommand # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default topplayerscommand.reset c.grant group default topplayerscommand.reset # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Add a kills/deaths ratio
Prints an extra column with the kill/death ratio for each player.
Generate this versionRank rewards
Gives scrap to the top three players when the leaderboard is reset.
Generate this versionGUI leaderboard
Shows the same top 10 list in a CUI panel instead of chat lines.
Generate this version
FAQ
Does the leaderboard persist across server restarts?
Yes. The dictionary is written to oxide/data/TopPlayersCommand.json on unload and every five minutes while running. Restarting the server keeps the numbers; deleting the file or running topplayers.reset confirm clears them.
How is playtime counted?
A one-minute timer adds 60 seconds to every connected, non-NPC player in BasePlayer.activePlayerList. Time spent disconnected or sleeping is not counted, so a player who afks in a safe zone still gains time but an offline player does not.
How do I install it on Oxide or Carbon?
Drop the compiled TopPlayersCommand.cs into oxide/plugins on an Oxide server, or carbon/plugins on Carbon. The plugin loads on the next restart or with oxide.reload TopPlayersCommand. Grant topplayerscommand.reset to your admin group if you want the console reset command.
Do team kills count as kills?
Yes, the plugin does not check teams. info.InitiatorPlayer on a teammate is still a player, so the kill is recorded. If you run a no-teamkill server this never happens, but if you allow friendly fire and want to exclude it, compare currentTeam before incrementing.
Can I show more than 10 players?
Change DisplayCount in oxide/config/TopPlayersCommand.json and reload the plugin. Keep it modest, because every row is a separate chat message and large lists push other chat out of view.
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 top players command
Customize