How to make a Rust warn system plugin (warning counts with auto kick and ban)
A moderator command plugin that records warnings per player in an Oxide data file, lists them, and automatically kicks or bans a player once their warning count reaches configurable thresholds. It uses no game hooks: everything runs through chat commands.
Most Rust servers rely on staff memory and screenshots to track player behaviour, which breaks down once you have more than a couple of moderators. A simple warning system fixes that: staff issue /warn, counts are persisted in oxide/data, and the plugin enforces your policy automatically at fixed thresholds.
This plugin stores one record per player with the current count, the lifetime count, and the individual reasons with timestamps and the moderator who issued them. /warnings <player> shows the counts and the most recent entries with a short "ago" label. When the current count reaches the kick threshold the player is kicked, and when it reaches the ban threshold they are banned through ServerUsers. A ban also clears the current count so the same records do not ban again on the next warning. Thresholds, ban duration and how many reasons are listed are all controlled from the config file.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Warn System", "RustPluginGenerator", "1.0.0")]
[Description("Records player warnings and automatically kicks or bans at configurable thresholds.")]
public class WarnSystem : RustPlugin
{
private const string PermUse = "warnsystem.use";
private const string DataFile = "WarnSystem";
private PluginConfig config;
private StoredData data;
private class PluginConfig
{
[JsonProperty("Warnings before automatic kick (0 disables)")]
public int KickAtWarnings { get; set; } = 3;
[JsonProperty("Warnings before automatic ban (0 disables)")]
public int BanAtWarnings { get; set; } = 5;
[JsonProperty("Ban duration in seconds (0 = permanent)")]
public long BanDurationSeconds { get; set; } = 0;
[JsonProperty("Max reasons shown in /warnings")]
public int MaxReasonsShown { get; set; } = 5;
[JsonProperty("Broadcast punishments to all players")]
public bool BroadcastPunishments { get; set; } = true;
}
private class WarningEntry
{
[JsonProperty("Reason")]
public string Reason { get; set; }
[JsonProperty("Time")]
public long Time { get; set; }
[JsonProperty("Moderator")]
public string Moderator { get; set; }
}
private class PlayerRecord
{
[JsonProperty("Name")]
public string Name { get; set; }
[JsonProperty("Current warnings")]
public int Current { get; set; }
[JsonProperty("Total warnings")]
public int Total { get; set; }
[JsonProperty("Entries")]
public List<WarningEntry> Entries { get; set; } = new List<WarningEntry>();
}
private class StoredData
{
public Dictionary<string, PlayerRecord> Players = new Dictionary<string, PlayerRecord>();
}
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>
{
["UsageWarn"] = "Usage: /warn <player> <reason>",
["UsageWarnings"] = "Usage: /warnings <player>",
["UsageUnwarn"] = "Usage: /unwarn <player> [amount]",
["NoPermission"] = "You do not have permission to use this command.",
["PlayerNotFound"] = "Player '{0}' was not found.",
["InvalidAmount"] = "Amount must be a positive number.",
["WarnAdded"] = "Added warning to {0} ({1}/{2}). Reason: {3}",
["WarnAddedTarget"] = "You received a warning: {0}",
["KickedTarget"] = "{0} was kicked for reaching {1} warnings.",
["BannedTarget"] = "{0} was banned for reaching {1} warnings.",
["NoWarnings"] = "{0} has no warnings.",
["ListHeader"] = "{0} - current warnings: {1}, lifetime: {2}",
["ListEntry"] = "{0}. {1} ({2}) by {3}",
["Unwarned"] = "Removed {0} warning(s) from {1}. Current: {2}",
["UnwarnNothing"] = "{0} has no warnings to remove."
}, 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()
{
data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(DataFile);
if (data == null) data = new StoredData();
if (data.Players == null) data.Players = new Dictionary<string, PlayerRecord>();
}
private void Unload()
{
SaveData();
}
private void SaveData()
{
if (data != null) Interface.Oxide.DataFileSystem.WriteObject(DataFile, data);
}
private bool HasPermission(BasePlayer player)
{
return player != null && permission.UserHasPermission(player.UserIDString, PermUse);
}
private BasePlayer FindPlayer(string nameOrId, out string userId)
{
userId = null;
if (string.IsNullOrEmpty(nameOrId)) return null;
BasePlayer target = BasePlayer.FindAwakeOrSleeping(nameOrId);
if (target == null)
{
ulong id;
if (ulong.TryParse(nameOrId, out id)) target = BasePlayer.FindByID(id);
}
if (target != null)
{
userId = target.UserIDString;
if (data.Players.ContainsKey(userId))
{
data.Players[userId].Name = target.displayName;
}
return target;
}
// fall back to a stored record for offline players
foreach (KeyValuePair<string, PlayerRecord> kvp in data.Players)
{
if (string.Equals(kvp.Value.Name, nameOrId, StringComparison.OrdinalIgnoreCase) || kvp.Key == nameOrId)
{
userId = kvp.Key;
return null;
}
}
return null;
}
private PlayerRecord GetRecord(string userId, string name)
{
PlayerRecord record;
if (!data.Players.TryGetValue(userId, out record))
{
record = new PlayerRecord { Name = name };
data.Players[userId] = record;
}
if (!string.IsNullOrEmpty(name)) record.Name = name;
return record;
}
[ChatCommand("warn")]
private void CmdWarn(BasePlayer player, string command, string[] args)
{
if (!HasPermission(player)) { SendReply(player, Msg("NoPermission", player.UserIDString)); return; }
if (args == null || args.Length < 2) { SendReply(player, Msg("UsageWarn", player.UserIDString)); return; }
string targetName = args[0];
string reason = string.Join(" ", args, 1, args.Length - 1);
string userId;
BasePlayer target = FindPlayer(targetName, out userId);
if (userId == null) { SendReply(player, Msg("PlayerNotFound", player.UserIDString, targetName)); return; }
PlayerRecord record = GetRecord(userId, target != null ? target.displayName : targetName);
record.Current++;
record.Total++;
record.Entries.Add(new WarningEntry
{
Reason = reason,
Time = Facepunch.Math.Epoch.Current,
Moderator = player.displayName
});
SendReply(player, Msg("WarnAdded", player.UserIDString, record.Name, record.Current, config.BanAtWarnings > 0 ? config.BanAtWarnings : record.Current, reason));
if (target != null && target.IsConnected) SendReply(target, Msg("WarnAddedTarget", target.UserIDString, reason));
ApplyThresholds(target, userId, record);
SaveData();
}
private void ApplyThresholds(BasePlayer target, string userId, PlayerRecord record)
{
if (config.BanAtWarnings > 0 && record.Current >= config.BanAtWarnings)
{
record.Current = 0;
Punish(target, userId, true);
if (config.BroadcastPunishments) Server.Broadcast(Msg("BannedTarget", null, record.Name, config.BanAtWarnings));
return;
}
if (config.KickAtWarnings > 0 && record.Current >= config.KickAtWarnings)
{
Punish(target, userId, false);
if (config.BroadcastPunishments) Server.Broadcast(Msg("KickedTarget", null, record.Name, config.KickAtWarnings));
}
}
private void Punish(BasePlayer target, string userId, bool ban)
{
ulong id;
if (!ulong.TryParse(userId, out id)) return;
if (ban)
{
if (config.BanDurationSeconds > 0)
{
ServerUsers.Set(id, ServerUsers.UserGroup.Banned, data.Players[userId].Name, "Warning threshold reached", config.BanDurationSeconds);
}
else
{
ServerUsers.Set(id, ServerUsers.UserGroup.Banned, data.Players[userId].Name, "Warning threshold reached", -1L);
}
ServerUsers.Save();
if (target != null && target.IsConnected)
{
target.Kick(config.BanDurationSeconds > 0
? string.Format("Banned for {0} seconds (warning threshold)", config.BanDurationSeconds)
: "Banned (warning threshold)");
}
return;
}
if (target != null && target.IsConnected)
{
target.Kick("Kicked (warning threshold)");
}
}
[ChatCommand("warnings")]
private void CmdWarnings(BasePlayer player, string command, string[] args)
{
if (!HasPermission(player)) { SendReply(player, Msg("NoPermission", player.UserIDString)); return; }
if (args == null || args.Length < 1) { SendReply(player, Msg("UsageWarnings", player.UserIDString)); return; }
string userId;
BasePlayer target = FindPlayer(args[0], out userId);
if (userId == null) { SendReply(player, Msg("PlayerNotFound", player.UserIDString, args[0])); return; }
PlayerRecord record;
if (!data.Players.TryGetValue(userId, out record) || (record.Current == 0 && record.Entries.Count == 0))
{
SendReply(player, Msg("NoWarnings", player.UserIDString, target != null ? target.displayName : args[0]));
return;
}
SendReply(player, Msg("ListHeader", player.UserIDString, record.Name, record.Current, record.Total));
int shown = 0;
for (int i = record.Entries.Count - 1; i >= 0 && shown < config.MaxReasonsShown; i--, shown++)
{
WarningEntry entry = record.Entries[i];
SendReply(player, Msg("ListEntry", player.UserIDString, shown + 1, entry.Reason, FormatAgo(entry.Time), entry.Moderator));
}
}
private static string FormatAgo(long unixTime)
{
long diff = Facepunch.Math.Epoch.Current - unixTime;
if (diff < 60) return diff + "s ago";
if (diff < 3600) return (diff / 60) + "m ago";
if (diff < 86400) return (diff / 3600) + "h ago";
return (diff / 86400) + "d ago";
}
[ChatCommand("unwarn")]
private void CmdUnwarn(BasePlayer player, string command, string[] args)
{
if (!HasPermission(player)) { SendReply(player, Msg("NoPermission", player.UserIDString)); return; }
if (args == null || args.Length < 1) { SendReply(player, Msg("UsageUnwarn", player.UserIDString)); return; }
int amount = 1;
if (args.Length > 1 && !int.TryParse(args[1], out amount))
{
SendReply(player, Msg("InvalidAmount", player.UserIDString));
return;
}
if (amount <= 0) { SendReply(player, Msg("InvalidAmount", player.UserIDString)); return; }
string userId;
BasePlayer target = FindPlayer(args[0], out userId);
if (userId == null) { SendReply(player, Msg("PlayerNotFound", player.UserIDString, args[0])); return; }
PlayerRecord record;
if (!data.Players.TryGetValue(userId, out record) || record.Current <= 0)
{
SendReply(player, Msg("UnwarnNothing", player.UserIDString, target != null ? target.displayName : args[0]));
return;
}
int removed = Mathf.Min(amount, record.Current);
record.Current -= removed;
for (int i = 0; i < removed && record.Entries.Count > 0; i++)
{
record.Entries.RemoveAt(record.Entries.Count - 1);
}
SendReply(player, Msg("Unwarned", player.UserIDString, removed, record.Name, record.Current));
SaveData();
}
}
}Hooks used: OnServerInitialized
Config file
Created automatically on first load at oxide/config/WarnSystem.json.
{
"Warnings before automatic kick (0 disables)": 3,
"Warnings before automatic ban (0 disables)": 5,
"Ban duration in seconds (0 = permanent)": 0,
"Max reasons shown in /warnings": 5,
"Broadcast punishments to all players": true
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/warn <player> <reason> | Adds a warning to the target player; kicks or bans them if a threshold is reached. | warnsystem.use |
/warnings <player> | Lists the target player's current warning count and the most recent reasons. | warnsystem.use |
/unwarn <player> [amount] | Removes one or more warnings from a player, defaults to one. | warnsystem.use |
| Permission | Grants |
|---|---|
warnsystem.use | Grants access to /warn, /warnings and /unwarn. |
How the code works
Storing warnings in a data file
Warnings are kept in
oxide/data/WarnSystem.jsonthroughInterface.Oxide.DataFileSystem. The file holds aStoredDataobject whosePlayersdictionary is keyed byUserIDString, and each value is a nestedPlayerRecordwithName,Current,Totaland anEntrieslist ofWarningEntryobjects holdingReason,TimeandModerator.The data is read in
OnServerInitializedand written back bySaveData()after every change, so a restart cannot lose a warning.UnloadcallsSaveData()once more so a reload keeps the file current.Adding a warning and checking thresholds
/warnresolves the target withFindPlayer, which triesBasePlayer.FindAwakeOrSleeping, thenBasePlayer.FindByIDfor numeric input, then falls back to matching a stored record name. The reason is built from the remaining arguments, and the entry is stamped withFacepunch.Math.Epoch.Currentand the moderator'sdisplayName.After the record is updated,
ApplyThresholdscomparesCurrentagainstBanAtWarningsfirst andKickAtWarningssecond. A value of 0 in either config key disables that step.Applying the punishment
Punishtakes the target and user id. Bans are written withServerUsers.SetusingServerUsers.UserGroup.Banned, followed byServerUsers.Save(), so offline players are banned too. That overload only takes a duration, so the ban is always permanent;BanDurationSecondscurrently only changes the kick message shown to a connected player.Connected targets are removed with
BasePlayer.Kick. WhenBroadcastPunishmentsis on,Server.Broadcastannounces the kick or ban using theKickedTargetorBannedTargetmessage.Listing warnings
/warningsprintsListHeaderwith the current and lifetime counts, then walksEntriesbackwards to show the most recentMaxReasonsShownreasons in theListEntryformat: index, reason, age and moderator.FormatAgoconverts the stored timestamp into seconds, minutes, hours or days usingFacepunch.Math.Epoch.Current. The command never edits the record, so staff can check history safely before deciding on a punishment.Removing warnings
/unwarnis the recovery path for mistakes. It removes the requested amount (default 1, validated against zero and non-numeric input) fromCurrent, clamped withMathf.Minso it never goes below zero, and drops the same number of entries from the end ofEntriesso the listing stays consistent.Lifetime
Totalis deliberately left untouched, so clearing a player's current warnings does not erase the fact that they have been warned before.SaveData()runs after the change.Config, permissions and messages
All strings live in
LoadDefaultMessagesand are retrieved through theMsghelper, so admins can edit wording without touching the DLL.KickAtWarnings,BanAtWarnings,BanDurationSeconds,MaxReasonsShownandBroadcastPunishmentsare the configurable knobs.Initregisterswarnsystem.use, and every command callsHasPermission, which checkspermission.UserHasPermissionwith the player'sUserIDString, before doing any work.
Install it on your server
Save WarnSystem.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/WarnSystem.json(Carbon:carbon/configs/WarnSystem.json). After editing, reload it:oxide.reload WarnSystem c.reload WarnSystem # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default warnsystem.use c.grant group default warnsystem.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Expire warnings over time
Removes current warnings after a configurable number of days so old behaviour stops counting.
Generate this versionDiscord webhook on warn
Posts a message to a Discord channel whenever a warning is given.
Generate this versionScrap penalty tier
Adds a scrap or item penalty as an extra tier below the kick threshold.
Generate this version
FAQ
Do I need a hook to track warnings?
No. Warnings are issued by staff through /warn, so the plugin only needs chat commands and data persistence. There is no game event that fires when a moderator decides a player should be warned.
How do I install it on Oxide or Carbon?
Place WarnSystem.cs in oxide/plugins, run oxide.reload WarnSystem from the server console, then edit oxide/config/WarnSystem.json and set your thresholds. Carbon loads the same file from carbon/plugins; no changes are required.
Can I warn players who are offline?
Yes. FindPlayer resolves online and sleeping players with BasePlayer.FindAwakeOrSleeping and BasePlayer.FindByID, and falls back to matching a name in the stored records. Bans are applied through ServerUsers plus ServerUsers.Save(), so an offline player is still blocked at their next connect.
What does BanDurationSeconds actually do?
It does not shorten the ban. ServerUsers.Set is called with a fixed permanent duration, and the config value only changes the kick message shown to a connected player. Set it to 0 for a plain permanent-ban message.
Why did the count reset after a ban?
When a warning reaches the ban threshold the plugin sets Current back to zero so the same warnings cannot trigger a second ban straight afterwards. The lifetime Total and the reason history stay in place for /warnings.
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 moderation plugins
- AFK kickTracks each connected player's movement and look direction with a repeating timer and kicks anyone who stays idle past a configured limit after aโฆOnServerInitializedOnPlayerConnected
- Chat anti-spamBlocks chat messages that exceed a rate limit or duplicate the player's previous message, and mutes repeat offenders for a configurable time.OnPlayerChat
- Chat muteA timed chat mute plugin: /mute <player> <minutes> [reason] stores a mute with an expiry in a data file, /unmute <player> clears it, andโฆOnServerInitializedOnPlayerChat
Last updated Oct 26, 2026.
Customize warnings
Customize