Skip to content
RustPluginGenerator

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

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

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

Chat and console commands
CommandWhat it doesPermission
/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
Permissions
PermissionGrants
warnsystem.useGrants access to /warn, /warnings and /unwarn.

How the code works

  1. Storing warnings in a data file

    Warnings are kept in oxide/data/WarnSystem.json through Interface.Oxide.DataFileSystem. The file holds a StoredData object whose Players dictionary is keyed by UserIDString, and each value is a nested PlayerRecord with Name, Current, Total and an Entries list of WarningEntry objects holding Reason, Time and Moderator.

    The data is read in OnServerInitialized and written back by SaveData() after every change, so a restart cannot lose a warning. Unload calls SaveData() once more so a reload keeps the file current.

  2. Adding a warning and checking thresholds

    /warn resolves the target with FindPlayer, which tries BasePlayer.FindAwakeOrSleeping, then BasePlayer.FindByID for numeric input, then falls back to matching a stored record name. The reason is built from the remaining arguments, and the entry is stamped with Facepunch.Math.Epoch.Current and the moderator's displayName.

    After the record is updated, ApplyThresholds compares Current against BanAtWarnings first and KickAtWarnings second. A value of 0 in either config key disables that step.

  3. Applying the punishment

    Punish takes the target and user id. Bans are written with ServerUsers.Set using ServerUsers.UserGroup.Banned, followed by ServerUsers.Save(), so offline players are banned too. That overload only takes a duration, so the ban is always permanent; BanDurationSeconds currently only changes the kick message shown to a connected player.

    Connected targets are removed with BasePlayer.Kick. When BroadcastPunishments is on, Server.Broadcast announces the kick or ban using the KickedTarget or BannedTarget message.

  4. Listing warnings

    /warnings prints ListHeader with the current and lifetime counts, then walks Entries backwards to show the most recent MaxReasonsShown reasons in the ListEntry format: index, reason, age and moderator.

    FormatAgo converts the stored timestamp into seconds, minutes, hours or days using Facepunch.Math.Epoch.Current. The command never edits the record, so staff can check history safely before deciding on a punishment.

  5. Removing warnings

    /unwarn is the recovery path for mistakes. It removes the requested amount (default 1, validated against zero and non-numeric input) from Current, clamped with Mathf.Min so it never goes below zero, and drops the same number of entries from the end of Entries so the listing stays consistent.

    Lifetime Total is 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.

  6. Config, permissions and messages

    All strings live in LoadDefaultMessages and are retrieved through the Msg helper, so admins can edit wording without touching the DLL. KickAtWarnings, BanAtWarnings, BanDurationSeconds, MaxReasonsShown and BroadcastPunishments are the configurable knobs.

    Init registers warnsystem.use, and every command calls HasPermission, which checks permission.UserHasPermission with the player's UserIDString, before doing any work.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip: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 version
  • Discord webhook on warn

    Posts a message to a Discord channel whenever a warning is given.

    Generate this version
  • Scrap 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.

Prompt: Generate a Rust Oxide warn system plugin: /warn, /warnings and /unwarn commands under warnsystem.use, warnings saved to an oxide data file, auto kick at 3 warnings and auto ban at 5, configurable messages.
Customize for my server

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

Last updated Oct 26, 2026.

Customize warnings

Customize