Skip to content
RustPluginGenerator

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

TopPlayersCommand.cs
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.

TopPlayersCommand.json
{
  "Players shown in leaderboard": 10,
  "Save interval (seconds)": 300
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/top killsPrints the top 10 players by kills.Everyone
/top deathsPrints the top 10 players by deaths.Everyone
/top playtimePrints the top 10 players by tracked playtime.Everyone
topplayers.resetClears all tracked stats after a confirmation argument.topplayerscommand.reset
Permissions
PermissionGrants
topplayerscommand.resetAllows running the topplayers.reset console command that wipes stored stats.

How the code works

  1. Storing stats per player

    A PlayerStats class holds Name, Kills, Deaths and Seconds. Everything lives in a Dictionary<ulong, PlayerStats> inside a StoredData wrapper, saved with Interface.Oxide.DataFileSystem.WriteObject("TopPlayersCommand", data) in Unload and every five minutes. The LoadDefaultConfig/LoadConfig pair sets SaveIntervalSeconds and DisplayCount from the config file, so admins can change how many rows /top prints without touching code.

  2. Counting kills and deaths

    OnEntityDeath(BaseCombatEntity entity, HitInfo info) runs for every death. Bail out when entity is not a BasePlayer and when info or info.InitiatorPlayer is null. Skip NPCs with IsNpc. Get or create both records, increment Kills on the killer and Deaths on the victim, and do nothing when killer and victim are the same player (suicide). Every value written here is also updated in victim.displayName so the leaderboard shows current names.

  3. Accumulating playtime

    A timer.Every(60f, ...) loop iterates BasePlayer.activePlayerList, skips NPCs and sleeping or disconnected players, and adds 60 to each record's Seconds. The timer reference is kept in a Timer field and destroyed in Unload. Because the loop reads live players, it never counts time spent offline, and a reconnecting player keeps the total already stored.

  4. Rendering the leaderboard

    /top is registered with [ChatCommand("top")]. It takes args[0], maps kills, deaths and playtime to a selector, orders the dictionary values descending with OrderByDescending(...) and takes config.DisplayCount entries. Each row is formatted with Msg("TopRow", ...), a language key that takes rank, name and a formatted value; playtime is printed as Xh Ym using a small FormatTime helper.

  5. Saving on disconnect and unload

    OnPlayerDisconnected(BasePlayer player, string reason) refreshes the stored display name from player.displayName so /top stays accurate after a rename. Unload destroys 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.

  6. Resetting between wipes

    [ConsoleCommand("topplayers.reset")] requires the topplayerscommand.reset permission and the literal argument confirm. It clears the dictionary, writes the empty data file and replies with arg.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

  1. Save TopPlayersCommand.cs

    Keep the file name exactly as the class name. Oxide matches them.
  2. 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.
  3. Edit the config if you want

    On first load the default config is written to oxide/config/TopPlayersCommand.json (Carbon: carbon/configs/TopPlayersCommand.json). After editing, reload it:oxide.reload TopPlayersCommand c.reload TopPlayersCommand # Carbon
  4. Grant the permission

    Give it to every player, or swap default for a group such as vip: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.

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.

Prompt: Create a Rust Oxide plugin named TopPlayersCommand that tracks kills, deaths and playtime per player and prints a top 10 list for /top kills, deaths or playtime from its own data file.
Customize for my server

Edit the prompt on the next screen. 2 free plugins a month, no card.

Last updated Oct 26, 2026.

Customize top players command

Customize