Skip to content
RustPluginGenerator

How to make a Rust leaderboard UI plugin (CUI top 10)

Tracks kills, deaths and K/D per player in oxide/data and renders a CUI top-10 board with /top. Uses OnEntityDeath to record kills/deaths and OnServerSave to flush data.

Chat-only leaderboards get buried in scrollback and don't show K/D cleanly. This plugin keeps a compact per-player record (kills, deaths, computed K/D) in a data file and renders the top ten killers in a centered CUI panel with a close button. Kills are counted in OnEntityDeath when the killing blow comes from a player and the victim is a connected player; suicides and self-damage are ignored. Deaths are incremented in the same hook, so both counters stay in sync. The board is sorted by kills, ties broken by fewer deaths. Data is flushed on OnServerSave and again in Unload. Only players with the permission can open the panel, and a console command wired to a single button removes it.

The plugin code

LeaderboardUI.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Game.Rust.Cui;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Leaderboard UI", "RustPluginGenerator", "1.0.0")]
    [Description("Tracks kills, deaths and K/D per player and shows a CUI top 10 leaderboard.")]
    public class LeaderboardUI : RustPlugin
    {
        private const string PermUse = "leaderboardui.use";
        private const string PanelName = "LeaderboardUI.Panel";
        private const string DataFileName = "LeaderboardUI";

        private PluginConfig config;
        private Dictionary<string, PlayerStats> stats;

        private class PluginConfig
        {
            [JsonProperty("Top Count")]
            public int TopCount = 10;

            [JsonProperty("Show Names")]
            public bool ShowNames = true;
        }

        private class PlayerStats
        {
            [JsonProperty("Kills")]
            public int Kills;

            [JsonProperty("Deaths")]
            public int Deaths;

            [JsonIgnore]
            public double Kd => Deaths == 0 ? Kills : (double)Kills / Deaths;
        }

        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>
            {
                ["Title"] = "Top {0} by Kills",
                ["NoData"] = "No kills recorded yet",
                ["NoPermission"] = "You do not have permission to use this.",
                ["Close"] = "Close"
            }, 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(PermUse, this);
        }

        private void OnServerInitialized()
        {
            stats = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, PlayerStats>>(DataFileName) ?? new Dictionary<string, PlayerStats>();
        }

        private void Unload()
        {
            foreach (var player in BasePlayer.activePlayerList)
                CuiHelper.DestroyUi(player, PanelName);

            if (stats != null)
                Interface.Oxide.DataFileSystem.WriteObject(DataFileName, stats);
        }

        private void OnServerSave()
        {
            if (stats != null)
                Interface.Oxide.DataFileSystem.WriteObject(DataFileName, stats);
        }

        private PlayerStats GetOrCreate(string userId)
        {
            PlayerStats entry;
            if (!stats.TryGetValue(userId, out entry))
            {
                entry = new PlayerStats();
                stats[userId] = entry;
            }
            return entry;
        }

        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;
            if (attacker.userID == victim.userID) return;

            GetOrCreate(attacker.UserIDString).Kills++;
            GetOrCreate(victim.UserIDString).Deaths++;
        }

        [ChatCommand("top")]
        private void CmdTop(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                SendReply(player, Msg("NoPermission", player.UserIDString));
                return;
            }

            OpenBoard(player);
        }

        [ConsoleCommand("leaderboard.close")]
        private void CcmdClose(ConsoleSystem.Arg arg)
        {
            var player = arg.Player();
            if (player == null) return;
            CuiHelper.DestroyUi(player, PanelName);
        }

        private void OpenBoard(BasePlayer player)
        {
            CuiHelper.DestroyUi(player, PanelName);

            var entries = stats.Values
                .OrderByDescending(s => s.Kills)
                .ThenBy(s => s.Deaths)
                .Take(Mathf.Clamp(config.TopCount, 3, 15))
                .ToList();

            var container = new CuiElementContainer();
            container.Add(new CuiPanel
            {
                Image = { Color = "0.08 0.08 0.08 0.92" },
                RectTransform = { AnchorMin = "0.33 0.25", AnchorMax = "0.67 0.75" },
                CursorEnabled = true
            }, "Overlay", PanelName);

            container.Add(new CuiLabel
            {
                Text = { Text = Msg("Title", player.UserIDString, config.TopCount), FontSize = 20, Align = TextAnchor.MiddleCenter, Color = "1 0.85 0.3 1" },
                RectTransform = { AnchorMin = "0 0.88", AnchorMax = "1 0.99" }
            }, PanelName);

            container.Add(new CuiButton
            {
                Button = { Command = "leaderboard.close", Color = "0.65 0.2 0.2 1" },
                RectTransform = { AnchorMin = "0.35 0.02", AnchorMax = "0.65 0.12" },
                Text = { Text = Msg("Close", player.UserIDString), FontSize = 14, Align = TextAnchor.MiddleCenter }
            }, PanelName);

            if (entries.Count == 0)
            {
                container.Add(new CuiLabel
                {
                    Text = { Text = Msg("NoData", player.UserIDString), FontSize = 14, Align = TextAnchor.MiddleCenter },
                    RectTransform = { AnchorMin = "0 0.45", AnchorMax = "1 0.55" }
                }, PanelName);
            }
            else
            {
                float top = 0.85f;
                float bottom = 0.15f;
                float rowHeight = (top - bottom) / entries.Count;

                for (int i = 0; i < entries.Count; i++)
                {
                    var entry = entries[i];
                    float rowMax = top - i * rowHeight;
                    float rowMin = rowMax - rowHeight + 0.005f;

                    container.Add(new CuiLabel
                    {
                        Text = { Text = $"#{i + 1}", FontSize = 14, Align = TextAnchor.MiddleLeft, Color = "1 0.85 0.3 1" },
                        RectTransform = { AnchorMin = $"0.03 {rowMin}", AnchorMax = $"0.13 {rowMax}" }
                    }, PanelName);

                    string name = config.ShowNames ? GetDisplayName(entry) : "Player";
                    container.Add(new CuiLabel
                    {
                        Text = { Text = name, FontSize = 14, Align = TextAnchor.MiddleLeft },
                        RectTransform = { AnchorMin = $"0.15 {rowMin}", AnchorMax = $"0.55 {rowMax}" }
                    }, PanelName);

                    container.Add(new CuiLabel
                    {
                        Text = { Text = $"K/D {entry.Kd:0.00}", FontSize = 14, Align = TextAnchor.MiddleCenter },
                        RectTransform = { AnchorMin = $"0.55 {rowMin}", AnchorMax = $"0.78 {rowMax}" }
                    }, PanelName);

                    container.Add(new CuiLabel
                    {
                        Text = { Text = $"{entry.Kills} / {entry.Deaths}", FontSize = 14, Align = TextAnchor.MiddleRight, Color = "0.7 0.85 0.7 1" },
                        RectTransform = { AnchorMin = $"0.78 {rowMin}", AnchorMax = $"0.97 {rowMax}" }
                    }, PanelName);
                }
            }

            CuiHelper.AddUi(player, container);
        }

        private string GetDisplayName(PlayerStats entry)
        {
            // Stats are keyed by id; resolve a live name when the owner is online.
            var online = BasePlayer.activePlayerList.FirstOrDefault(p => stats.ContainsKey(p.UserIDString) && ReferenceEquals(stats[p.UserIDString], entry));
            return online != null ? online.displayName : "Offline Player";
        }
    }
}

Hooks used: OnServerInitialized, OnServerSave, OnEntityDeath

Config file

Created automatically on first load at oxide/config/LeaderboardUI.json.

LeaderboardUI.json
{
  "Top Count": 10,
  "Show Names": true
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/topOpens the top 10 kills leaderboard panelleaderboardui.use
leaderboard.closeConsole command fired by the Close button; removes the leaderboard panel for the calling playerEveryone
Permissions
PermissionGrants
leaderboardui.useAllows the player to open the leaderboard with /top

How the code works

  1. Data model and file

    Each entry is a small nested class PlayerStats with Kills and Deaths fields. Everything is stored in a Dictionary<string, PlayerStats> keyed by UserIDString. It is loaded in OnServerInitialized with Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, PlayerStats>>(DataFileName) and written back with WriteObject. If the file is missing or null you fall back to a new dictionary, so a fresh wipe never throws.

  2. Counting kills and deaths

    OnEntityDeath receives a BaseCombatEntity and a HitInfo. Check info.InitiatorPlayer and cast entity as BasePlayer; when either is null or an NPC, return. Compare the two userID values so a suicide or self-kill is not counted as a kill. Increment Kills on the attacker and Deaths on the victim, creating entries on demand with GetOrCreate. This hook runs on every entity death, so it stays free of logging.

  3. Building the CUI panel

    A CuiElementContainer starts with a CuiPanel parented to Overlay, dark with CursorEnabled = true. A header CuiLabel shows the title, then one row per entry is added in a loop: a rank label, a name label, the computed K/D in the middle and kills / deaths on the right. Rows are positioned by computing AnchorMin/AnchorMax from the row index so the layout scales with panel height.

  4. Close button and cleanup

    A CuiButton under the rows carries Button.Command = "leaderboard.close". The console command is registered with [ConsoleCommand("leaderboard.close")], resolves arg.Player() and calls CuiHelper.DestroyUi on the panel name. Unload iterates BasePlayer.activePlayerList and destroys the panel for everyone, so a reload never leaves a stuck window.

  5. Saving on schedule

    OnServerSave calls Interface.Oxide.DataFileSystem.WriteObject with the dictionary. Rust triggers server save on a timer and during restarts, so paired with the Unload write this covers crashes and normal shutdowns. Because writes only touch the dictionary, no locking is needed and the hook stays cheap.

Install it on your server

  1. Save LeaderboardUI.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/LeaderboardUI.json (Carbon: carbon/configs/LeaderboardUI.json). After editing, reload it:oxide.reload LeaderboardUI c.reload LeaderboardUI # Carbon
  4. Grant the permission

    Give it to every player, or swap default for a group such as vip:oxide.grant group default leaderboardui.use c.grant group default leaderboardui.use # Carbon

Variations

Each one opens the generator with a ready prompt you can tweak.

FAQ

Where is the leaderboard data stored?

In oxide/data/LeaderboardUI.json, written with Interface.Oxide.DataFileSystem. The file is a flat dictionary keyed by Steam ID with Kills and Deaths, so you can edit or wipe it by hand while the server is stopped.

Do deaths from NPCs, fall damage or radiation count?

Yes. Every death of a connected player adds one death, but kills only increase when HitInfo.InitiatorPlayer is set, so NPC kills do not add to anyone's kill count. Suicides and self-damage are skipped entirely.

How do I change the top-10 limit?

Edit Top Count in oxide/config/LeaderboardUI.json. Values above 10 make rows shorter; the layout is calculated from the row count, so anything from 3 to 15 still renders cleanly after you reload the plugin.

Does this work on Carbon?

Yes. Carbon loads the same .cs file and exposes the same hooks and Interface.Oxide.DataFileSystem API, so no changes are needed.

Why does nothing show in the panel?

The board only fills in as players die. Limit yourself to leaderboardui.use and confirm the data file exists after the first server save if you suspect the write is failing.

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 LeaderboardUI that tracks kills and deaths per player in oxide/data, opens a CUI top 10 with /top for permission leaderboardui.use and a close button, and saves on server save.
Customize for my server

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

Last updated Oct 26, 2026.

Customize leaderboard ui

Customize