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
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.
{
"Top Count": 10,
"Show Names": true
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/top | Opens the top 10 kills leaderboard panel | leaderboardui.use |
leaderboard.close | Console command fired by the Close button; removes the leaderboard panel for the calling player | Everyone |
| Permission | Grants |
|---|---|
leaderboardui.use | Allows the player to open the leaderboard with /top |
How the code works
Data model and file
Each entry is a small nested class
PlayerStatswithKillsandDeathsfields. Everything is stored in aDictionary<string, PlayerStats>keyed byUserIDString. It is loaded inOnServerInitializedwithInterface.Oxide.DataFileSystem.ReadObject<Dictionary<string, PlayerStats>>(DataFileName)and written back withWriteObject. If the file is missing or null you fall back to a new dictionary, so a fresh wipe never throws.Counting kills and deaths
OnEntityDeathreceives aBaseCombatEntityand aHitInfo. Checkinfo.InitiatorPlayerand castentity as BasePlayer; when either is null or an NPC, return. Compare the twouserIDvalues so a suicide or self-kill is not counted as a kill. IncrementKillson the attacker andDeathson the victim, creating entries on demand withGetOrCreate. This hook runs on every entity death, so it stays free of logging.Building the CUI panel
A
CuiElementContainerstarts with aCuiPanelparented toOverlay, dark withCursorEnabled = true. A headerCuiLabelshows 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 andkills / deathson the right. Rows are positioned by computingAnchorMin/AnchorMaxfrom the row index so the layout scales with panel height.Close button and cleanup
A
CuiButtonunder the rows carriesButton.Command = "leaderboard.close". The console command is registered with[ConsoleCommand("leaderboard.close")], resolvesarg.Player()and callsCuiHelper.DestroyUion the panel name.UnloaditeratesBasePlayer.activePlayerListand destroys the panel for everyone, so a reload never leaves a stuck window.Saving on schedule
OnServerSavecallsInterface.Oxide.DataFileSystem.WriteObjectwith the dictionary. Rust triggers server save on a timer and during restarts, so paired with theUnloadwrite 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
Save LeaderboardUI.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/LeaderboardUI.json(Carbon:carbon/configs/LeaderboardUI.json). After editing, reload it:oxide.reload LeaderboardUI c.reload LeaderboardUI # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip: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.
Rank every stat
Add a second tab showing top 10 by deaths and by K/D.
Generate this versionReset on wipe
Wipe the leaderboard data automatically on a new save.
Generate this versionReward the leader
Pay the top killer on a timer via Economics.
Generate this version
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.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More ui plugins
- Admin menuBuilds a CUI admin panel that opens with /am and renders one button per action defined in the config, each button running a console command throughโฆ
- Announcement bannerAdds an /announce chat command that draws a CUI banner at the top of every connected player's screen for a configurable duration, plus timedโฆOnServerInitializedOnPlayerConnected
- Command shortcut menuAdds a /menu chat command that opens a CUI grid of configurable buttons; each button closes the panel and runs its configured chat command for thatโฆ
Last updated Oct 26, 2026.
Customize leaderboard ui
Customize