How to make a Rust player report plugin (Oxide/Carbon /report command)
Adds a `/report <player> <reason>` chat command that saves the report with timestamp and reporter, notifies online admins with permission, lists recent reports via `/reports`, and enforces a per-player cooldown. It is built from chat command handlers and data persistence, with no game hooks required.
Rust has no built-in way for players to flag cheating or bad behavior, so admins usually rely on Discord messages or Steam profiles that lack context. This plugin gives players a single /report <player> <reason> command. Each report is stored with the reporter, target, reason and a Unix timestamp in oxide/data, so it survives restarts. Every online player with the reportcommand.notify permission receives a chat notification with the details as the report arrives. Admins use /reports to see the most recent entries newest-first. A configurable cooldown stops spam: each player can only file one report per interval. The whole flow is driven by [ChatCommand] handlers and Oxide's data file system, so nothing depends on game hooks that might change between Rust updates.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Report Command", "RustPluginGenerator", "1.0.0")]
[Description("Adds /report and /reports commands with admin notifications, storage and cooldown.")]
public class ReportCommand : RustPlugin
{
private const string PermUse = "reportcommand.use";
private const string PermAdmin = "reportcommand.admin";
private const string PermNotify = "reportcommand.notify";
private const string DataFile = "ReportCommand";
private PluginConfig config;
private List<ReportEntry> reports = new List<ReportEntry>();
private readonly Dictionary<ulong, float> lastReport = new Dictionary<ulong, float>();
private class PluginConfig
{
[JsonProperty("Cooldown seconds")]
public int CooldownSeconds { get; set; } = 120;
[JsonProperty("Max stored reports")]
public int MaxStoredReports { get; set; } = 500;
[JsonProperty("Reports shown per page")]
public int ReportsShownPerPage { get; set; } = 10;
}
private class ReportEntry
{
public string ReporterName { get; set; }
public string ReporterId { get; set; }
public string TargetName { get; set; }
public string TargetId { get; set; }
public string Reason { get; set; }
public long CreatedUtc { get; set; }
}
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>
{
["Usage"] = "Usage: /report <player> <reason>",
["NotFound"] = "Player '{0}' was not found.",
["SelfReport"] = "You cannot report yourself.",
["Cooldown"] = "You must wait {0} more second(s) before reporting again.",
["Received"] = "Your report against {0} has been submitted.",
["Notify"] = "Report: {0} ({1}) reported {2} ({3}) for: {4}",
["NoReports"] = "There are no stored reports.",
["ReportsHeader"] = "Last {0} report(s):",
["ReportLine"] = "{0} | {1} -> {2}: {3}",
["NoPermission"] = "You don't have permission to use this command."
}, 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);
permission.RegisterPermission(PermAdmin, this);
permission.RegisterPermission(PermNotify, this);
}
private void OnServerInitialized()
{
reports = Interface.Oxide.DataFileSystem.ReadObject<List<ReportEntry>>(DataFile) ?? new List<ReportEntry>();
}
private void Unload()
{
SaveData();
}
private void SaveData()
{
Interface.Oxide.DataFileSystem.WriteObject(DataFile, reports);
}
[ChatCommand("report")]
private void CmdReport(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
player.ChatMessage(Msg("NoPermission", player.UserIDString));
return;
}
if (args == null || args.Length < 2)
{
player.ChatMessage(Msg("Usage", player.UserIDString));
return;
}
BasePlayer target = BasePlayer.FindAwakeOrSleeping(args[0]);
if (target == null && ulong.TryParse(args[0], out ulong targetId))
{
target = BasePlayer.FindByID(targetId);
}
if (target == null)
{
player.ChatMessage(Msg("NotFound", player.UserIDString, args[0]));
return;
}
if (target.userID == player.userID)
{
player.ChatMessage(Msg("SelfReport", player.UserIDString));
return;
}
bool isNotifier = permission.UserHasPermission(player.UserIDString, PermNotify);
if (!isNotifier && config.CooldownSeconds > 0)
{
float now = Time.realtimeSinceStartup;
if (lastReport.TryGetValue(player.userID, out float prev))
{
float remaining = config.CooldownSeconds - (now - prev);
if (remaining > 0f)
{
player.ChatMessage(Msg("Cooldown", player.UserIDString, Mathf.CeilToInt(remaining)));
return;
}
}
}
string reason = string.Join(" ", args, 1, args.Length - 1).Trim();
if (string.IsNullOrEmpty(reason))
{
player.ChatMessage(Msg("Usage", player.UserIDString));
return;
}
var entry = new ReportEntry
{
ReporterName = player.displayName,
ReporterId = player.UserIDString,
TargetName = target.displayName,
TargetId = target.UserIDString,
Reason = reason,
CreatedUtc = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
reports.Add(entry);
TrimReports();
SaveData();
lastReport[player.userID] = Time.realtimeSinceStartup;
player.ChatMessage(Msg("Received", player.UserIDString, target.displayName));
NotifyAdmins(entry);
}
[ChatCommand("reports")]
private void CmdReports(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermAdmin))
{
player.ChatMessage(Msg("NoPermission", player.UserIDString));
return;
}
if (reports.Count == 0)
{
player.ChatMessage(Msg("NoReports", player.UserIDString));
return;
}
int limit = Mathf.Max(1, config.ReportsShownPerPage);
int shown = 0;
player.ChatMessage(Msg("ReportsHeader", player.UserIDString, limit));
for (int i = reports.Count - 1; i >= 0 && shown < limit; i--, shown++)
{
var r = reports[i];
string time = DateTimeOffset.FromUnixTimeSeconds(r.CreatedUtc).ToLocalTime().ToString("HH:mm");
player.ChatMessage(Msg("ReportLine", player.UserIDString, time, r.ReporterName, r.TargetName, r.Reason));
}
}
private void NotifyAdmins(ReportEntry entry)
{
foreach (var admin in BasePlayer.activePlayerList)
{
if (admin == null || !admin.IsConnected) continue;
if (!permission.UserHasPermission(admin.UserIDString, PermNotify)) continue;
admin.ChatMessage(Msg("Notify", admin.UserIDString, entry.ReporterName, entry.ReporterId, entry.TargetName, entry.TargetId, entry.Reason));
}
}
private void TrimReports()
{
int max = Mathf.Max(1, config.MaxStoredReports);
if (reports.Count <= max) return;
reports.RemoveRange(0, reports.Count - max);
}
}
}Hooks used: OnServerInitialized
Config file
Created automatically on first load at oxide/config/ReportCommand.json.
{
"Cooldown seconds": 120,
"Max stored reports": 500,
"Reports shown per page": 10
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/report <player> <reason> | Files a report against a player. Resolves the target by name or Steam ID, stores it with the reporter and timestamp, and notifies online admins. | reportcommand.use |
/reports | Shows the most recent reports (newest first) with time, reporter, target and reason. | reportcommand.admin |
| Permission | Grants |
|---|---|
reportcommand.use | Allows a player to file reports with /report. |
reportcommand.admin | Allows listing recent reports with /reports. |
reportcommand.notify | Grants live chat notification of every new report while online. |
How the code works
Storing reports in a data file
Reports live in
oxide/data/ReportCommand.jsonas aList<ReportEntry>. EachReportEntryhasReporterName,ReporterId,TargetName,TargetId,ReasonandCreatedUtc, a Unix timestamp taken fromDateTimeOffset.UtcNow.ToUnixTimeSeconds().OnServerInitializedloads the list withInterface.Oxide.DataFileSystem.ReadObjectand falls back to an empty list when the file is missing.SaveDatawrites it withWriteObjectafter every new report and again inUnload.TrimReportsenforces theMax stored reportskey by dropping the oldest entries first.Resolving the target player
/reporttakes the target as the first argument and joins the remaining arguments into the reason. The target can be a name or a Steam ID. The handler first triesBasePlayer.FindAwakeOrSleeping, then, if that returns null, parses the argument withulong.TryParseand callsBasePlayer.FindByID. If nothing matches, the player sees theNotFoundlang message. Because sleeping players are included, someone who just logged off can still be reported.Cooldown handling
The handler keeps a
Dictionary<ulong, float>of last report times usingTime.realtimeSinceStartup. If the gap is shorter than theCooldown secondsconfig value, the player sees theCooldownmessage with the remaining whole seconds fromMathf.CeilToInt. Players holdingreportcommand.notifyskip the cooldown check entirely. The dictionary is not persisted, so the cooldown resets on a plugin reload or server restart, which is acceptable for a spam gate.Notifying online admins
After saving,
NotifyAdminsloops overBasePlayer.activePlayerListand skips anyone null, disconnected, or withoutreportcommand.notify. Each remaining player gets theNotifymessage viaplayer.ChatMessage, including the reporter name and ID, the target name and ID, and the reason. Players without the permission never see these notifications. The reporter also gets theReceivedconfirmation through the same lang system.Listing with /reports
/reportsrequiresreportcommand.admin. Ifreportsis empty it sends theNoReportsmessage. Otherwise it sends theReportsHeaderline and walks the stored list backwards, sending up toReports shown per pageentries asReportLinemessages. Each line formats the storedCreatedUtcwithDateTimeOffset.FromUnixTimeSeconds(...).ToLocalTime()as an hour-and-minute string, then shows reporter, target and reason.Config and messages
PluginConfigholdsCooldown seconds(default 120),Max stored reports(default 500) andReports shown per page(default 10).LoadConfiguses the standard try/catch pattern, and a broken file falls back to defaults with a warning. All player text goes throughlang.RegisterMessagesand theMsghelper, so the plugin can be translated without touching the code. Permissions are registered inInit.
Install it on your server
Save ReportCommand.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/ReportCommand.json(Carbon:carbon/configs/ReportCommand.json). After editing, reload it:oxide.reload ReportCommand c.reload ReportCommand # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default reportcommand.use c.grant group default reportcommand.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Discord relay
Forward each accepted report to a webhook instead of only chat.
Generate this versionAdmin review UI
Show reports in an in-game CUI list with a teleport button.
Generate this versionLog to oxide log
Write reports to a log file for audit.
Generate this version
FAQ
Do reports survive a server restart?
Yes. Each accepted report is written to oxide/data/ReportCommand.json via Interface.Oxide.DataFileSystem and reloaded in OnServerInitialized. Map wipes do not touch oxide/data, so old reports remain until they are trimmed by the Max stored reports limit or the file is deleted.
Why does /report not find a player?
The target argument must match an existing player name or Steam ID. The plugin checks awake and sleeping players through FindAwakeOrSleeping and FindByID. A player who has never joined or whose account was deleted cannot be resolved, and you get the NotFound message.
Can players spam the command?
No, unless they hold reportcommand.notify. Each player is limited to one report per Cooldown seconds. The cooldown is tracked in memory, so it resets after a plugin reload or server restart.
How do I install it on Oxide or Carbon?
Drop the compiled .cs file into oxide/plugins, run oxide.reload ReportCommand from the server console, then grant permissions with oxide.grant group admin reportcommand.admin and oxide.grant group admin reportcommand.notify. Carbon loads the same file from its plugins folder.
Who sees new report notifications?
Any connected player holding reportcommand.notify. That is separate from reportcommand.admin, so a moderator can receive alerts without being able to list old reports, or vice versa.
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 player reports
Customize