Skip to content
RustPluginGenerator

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

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

GatherStats.json
{
  "Tracked shortnames": [
    "wood",
    "stones",
    "metal.ore",
    "sulfur.ore",
    "cloth"
  ],
  "Save Interval Seconds": 120
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/gatheredPrints your gathered totals for the current wipe.Everyone
/gathered <player name or id>Prints another player's totals. Requires permission.gatherstats.viewothers
Permissions
PermissionGrants
gatherstats.viewothersAllows /gathered <player> to look up another player's totals.

How the code works

  1. Capture the hook arguments

    OnDispenserGathered fires for every resource that leaves a ResourceDispenser (tree, ore node, corpse of an animal). The plugin reads item.info.shortname and item.amount first and bails out when the shortname is not in the tracked list, so the rest of the hook stays cheap. The entity parameter is the BasePlayer receiving the resource, and it is null-checked before anything else.

  2. Credit collectibles

    OnCollectiblePickup(CollectibleEntity collectibleEntity, BasePlayer reciever, bool eat) handles hemp and other ground pickups. Because it returns an object, the plugin must return null to leave default pickup behavior alone. The hook resolves the picked shortname from the collectible's itemList (the ItemAmount entries on the entity) and adds the amount to the receiver's totals.

  3. Store totals in oxide/data

    A Dictionary<string, Dictionary<string, double>> keyed by the player's UserIDString then by shortname is loaded with Interface.Oxide.DataFileSystem.ReadObject in OnServerInitialized. Writes go through WriteObject, throttled by a Timer created with timer.Every, so a busy farm does not rewrite the file on every swing. The timer reference is destroyed in Unload.

  4. 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.

  5. 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 requires gatherstats.viewothers and resolves the target by calling BasePlayer.FindAwakeOrSleeping, which also finds offline players by ID.

  6. Formatting through lang

    LoadDefaultMessages registers a header line plus one line per resource, and Msg wraps lang.GetMessage with string.Format. Amounts are formatted with ToString("N0") after converting to long, so admins see 12,480 rather than 12480.0000001. Editing oxide/lang/en/GatherStats.json changes wording without touching the plugin.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip: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.

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.

Prompt: Create a Rust Oxide plugin named GatherStats that tracks wood, stone, metal.ore, sulfur.ore and cloth gathered per player via OnDispenserGathered and OnCollectiblePickup, saves to oxide/data every 120 seconds, resets on OnNewSave, and adds /gathered plus /gathered <player> behind gatherstats.viewothers.
Customize for my server

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

Last updated Oct 26, 2026.

Customize gather statistics

Customize