How to make a Rust gather statistics plugin (wipe totals)
Tracks wood, stone, metal ore, sulfur ore and cloth gathered per player for the current wipe, persists the totals in oxide/data, and shows them with /gathered. Uses OnDispenserGathered, OnCollectiblePickup and OnNewSave.
Rust gives players no way to see how much they actually farmed since the last wipe. A kill feed or a raid log only tells you who destroyed what, not who hit the nodes. Admins therefore have to guess which players carry their base, and players argue about it without data.
This plugin hooks the moment a resource enters a player's inventory from a dispenser (trees, ore nodes, animals) and from collectible entities (hemp, ground pickups). It credits only the five tracked shortnames, keeps a per-userIDString total in oxide/data, and resets everything when a new save file is created. /gathered prints your own totals, and a permission lets staff read another player's numbers. Totals survive restarts and plugin reloads because they are written to disk.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Gather Stats", "RustPluginGenerator", "1.0.0")]
[Description("Tracks wood, stone, metal ore, sulfur ore and cloth gathered per player for the current wipe.")]
public class GatherStats : RustPlugin
{
private const string PermViewOthers = "gatherstats.viewothers";
private PluginConfig config;
private Dictionary<string, Dictionary<string, double>> totals = new Dictionary<string, Dictionary<string, double>>();
private Timer saveTimer;
private class PluginConfig
{
[JsonProperty("Tracked shortnames")]
public List<string> TrackedShortnames = new List<string>
{
"wood", "stones", "metal.ore", "sulfur.ore", "cloth"
};
[JsonProperty("Save Interval Seconds")]
public float SaveInterval = 120f;
}
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>
{
["Header"] = "Gathered this wipe ({0}):",
["Line"] = "{0}: {1}",
["NoData"] = "{0} has not gathered any tracked resources this wipe.",
["Self"] = "You",
["NoPermission"] = "You do not have permission to view another player's totals.",
["NotFound"] = "Player not found."
}, 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(PermViewOthers, this);
}
private void OnServerInitialized()
{
totals = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, Dictionary<string, double>>>(Name)
?? new Dictionary<string, Dictionary<string, double>>();
saveTimer?.Destroy();
saveTimer = timer.Every(Mathf.Max(10f, config.SaveInterval), SaveData);
}
private void Unload()
{
saveTimer?.Destroy();
saveTimer = null;
SaveData();
}
private void SaveData()
{
Interface.Oxide.DataFileSystem.WriteObject(Name, totals);
}
private bool IsTracked(string shortname)
{
return shortname != null && config.TrackedShortnames.Contains(shortname);
}
private void AddGathered(BasePlayer player, string shortname, double amount)
{
if (player == null || amount <= 0 || !IsTracked(shortname)) return;
Dictionary<string, double> playerTotals;
if (!totals.TryGetValue(player.UserIDString, out playerTotals))
{
playerTotals = new Dictionary<string, double>();
totals[player.UserIDString] = playerTotals;
}
double current;
playerTotals.TryGetValue(shortname, out current);
playerTotals[shortname] = current + amount;
}
private void OnDispenserGathered(ResourceDispenser resourceDispenser, BasePlayer entity, Item item)
{
if (item?.info == null || entity == null) return;
AddGathered(entity, item.info.shortname, item.amount);
}
private object OnCollectiblePickup(CollectibleEntity collectibleEntity, BasePlayer reciever, bool eat)
{
if (collectibleEntity?.itemList == null || reciever == null) return null;
foreach (ItemAmount itemAmount in collectibleEntity.itemList)
{
if (itemAmount?.itemDef == null) continue;
if (!IsTracked(itemAmount.itemDef.shortname)) continue;
AddGathered(reciever, itemAmount.itemDef.shortname, itemAmount.amount);
}
return null;
}
private void OnNewSave(string strFilename)
{
totals.Clear();
SaveData();
PrintWarning("New save detected, gather statistics were reset.");
}
[ChatCommand("gathered")]
private void CmdGathered(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (args == null || args.Length == 0)
{
ShowTotals(player, player.UserIDString, Msg("Self", player.UserIDString));
return;
}
if (!permission.UserHasPermission(player.UserIDString, PermViewOthers))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
BasePlayer target = BasePlayer.FindAwakeOrSleeping(args[0]);
if (target == null)
{
SendReply(player, Msg("NotFound", player.UserIDString));
return;
}
ShowTotals(player, target.UserIDString, target.displayName);
}
private void ShowTotals(BasePlayer viewer, string targetId, string displayName)
{
Dictionary<string, double> playerTotals;
if (!totals.TryGetValue(targetId, out playerTotals) || playerTotals.Count == 0)
{
SendReply(viewer, Msg("NoData", viewer.UserIDString, displayName));
return;
}
SendReply(viewer, Msg("Header", viewer.UserIDString, displayName));
foreach (string shortname in config.TrackedShortnames)
{
double amount;
if (!playerTotals.TryGetValue(shortname, out amount) || amount <= 0) continue;
ItemDefinition def = ItemManager.FindItemDefinition(shortname);
string label = def != null ? def.displayName.english : shortname;
SendReply(viewer, Msg("Line", viewer.UserIDString, label, ((long)amount).ToString("N0")));
}
}
}
}Hooks used: OnServerInitialized, OnDispenserGathered, OnCollectiblePickup, OnNewSave
Config file
Created automatically on first load at oxide/config/GatherStats.json.
{
"Tracked shortnames": [
"wood",
"stones",
"metal.ore",
"sulfur.ore",
"cloth"
],
"Save Interval Seconds": 120
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/gathered | Prints your gathered totals for the current wipe. | Everyone |
/gathered <player name or id> | Prints another player's totals. Requires permission. | gatherstats.viewothers |
| Permission | Grants |
|---|---|
gatherstats.viewothers | Allows /gathered <player> to look up another player's totals. |
How the code works
Capture the hook arguments
OnDispenserGatheredfires for every resource that leaves aResourceDispenser(tree, ore node, corpse of an animal). The plugin readsitem.info.shortnameanditem.amountfirst and bails out when the shortname is not in the tracked list, so the rest of the hook stays cheap. Theentityparameter is theBasePlayerreceiving the resource, and it is null-checked before anything else.Credit collectibles
OnCollectiblePickup(CollectibleEntity collectibleEntity, BasePlayer reciever, bool eat)handles hemp and other ground pickups. Because it returns an object, the plugin must returnnullto leave default pickup behavior alone. The hook resolves the picked shortname from the collectible'sitemList(theItemAmountentries on the entity) and adds the amount to the receiver's totals.Store totals in oxide/data
A
Dictionary<string, Dictionary<string, double>>keyed by the player'sUserIDStringthen by shortname is loaded withInterface.Oxide.DataFileSystem.ReadObjectinOnServerInitialized. Writes go throughWriteObject, throttled by aTimercreated withtimer.Every, so a busy farm does not rewrite the file on every swing. The timer reference is destroyed inUnload.Reset on a fresh wipe
OnNewSave(string strFilename)fires when the server generates a new save file. The plugin clears the in-memory dictionary, writes the empty object to disk and logs one line. This keeps the numbers tied to a wipe instead of accumulating forever. If you wipe without deleting the save file, the totals carry over, which matches what the hook actually signals.The /gathered command
[ChatCommand("gathered")]receives(BasePlayer player, string command, string[] args). With no arguments it renders the caller's own totals through the language file. With an argument it requiresgatherstats.viewothersand resolves the target by callingBasePlayer.FindAwakeOrSleeping, which also finds offline players by ID.Formatting through lang
LoadDefaultMessagesregisters a header line plus one line per resource, andMsgwrapslang.GetMessagewithstring.Format. Amounts are formatted withToString("N0")after converting tolong, so admins see12,480rather than12480.0000001. Editingoxide/lang/en/GatherStats.jsonchanges wording without touching the plugin.
Install it on your server
Save GatherStats.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/GatherStats.json(Carbon:carbon/configs/GatherStats.json). After editing, reload it:oxide.reload GatherStats c.reload GatherStats # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default gatherstats.viewothers c.grant group default gatherstats.viewothers # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Weekly leaderboard
Adds a top-10 listing to /gathered.
Generate this versionReward every node
Pays scrap through Economics when a threshold is reached.
Generate this versionTrack everything
Removes the shortname filter and counts all dispenser output.
Generate this version
FAQ
Does the plugin count resources picked up from a box or a raid?
No. Only OnDispenserGathered and OnCollectiblePickup are hooked, so only resources that come out of a node, an animal carcass or a ground collectible are counted. Looting a container or taking items from a corpse inventory does not increase the totals.
Why is the cloth number lower than what I farmed?
Collectible cloth counts, but cloth pulled from a recycled item or a container does not. The plugin also only credits the exact shortnames in the config list, so if you added a custom resource with a different shortname it has to be added there too.
How do I install it on Oxide or Carbon?
Drop the compiled GatherStats.cs into oxide/plugins, then run oxide.reload GatherStats from the console. Carbon loads the same file from the same folder. The data file appears at oxide/data/GatherStats.json after the first save interval.
When exactly do the numbers reset?
When OnNewSave fires, which happens after the server creates a new save file on startup. Deleting the map and save files before a wipe triggers it. If you keep the same save file across a wipe, the totals continue from the previous wipe.
Can I change how often the data file is written?
Yes. The Save Interval Seconds config key controls the timer.Every interval. A shorter interval loses fewer numbers if the server crashes, at the cost of more disk writes. Totals are also flushed on unload.
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 stats plugins
- Death cause statsThis plugin records every player death, classifies the cause from the HitInfo passed to OnPlayerDeath, and stores running totals per player soโฆOnServerInitializedOnPlayerDeath
- Last seen commandRecords a timestamp for every player on disconnect and answers /seen <name> with a relative time such as '2 hours ago' or 'online now'.OnServerInitializedOnPlayerConnectedOnPlayerDisconnected
- Longest kill shotTracks each player's longest projectile kill distance and the server-wide record, saved to oxide/data, with a /longest chat command and anโฆOnServerInitializedOnPlayerDeath
Last updated Oct 26, 2026.
Customize gather statistics
Customize