Skip to content
RustPluginGenerator

How to make a Rust entity cleanup plugin (tool cupboard aware)

Adds a console command and an optional timed job that delete configured entity types by short prefab name unless they are inside a tool cupboard range, then reports the removal count. It uses OnServerInitialized to start the timer and BaseNetworkable.serverEntities to scan.

Stray sleeping bags, campfires, small stashes and boxes pile up on any server that has been running for a while. Vanilla Rust only cleans up entities that decay naturally, and many deployables never do, so the entity count keeps growing and saves get slower. The usual fix is a broad wipe of all entities, which is too blunt and can hit player-built items inside bases.

This plugin takes a more careful approach. It walks the server's network entity list, matches entities against a list of short prefab names from the config, and skips anything standing inside the range of a tool cupboard. Everything else is killed. You can trigger a pass manually from the server console, or let a repeating timer do it. Both output a chat and console summary with how many entities were checked, removed and skipped because they were protected.

The plugin code

EntityCleanup.cs
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Entity Cleanup", "RustPluginGenerator", "1.0.0")]
    [Description("Removes configured entity types that are not protected by a tool cupboard.")]
    public class EntityCleanup : RustPlugin
    {
        private const string PermUse = "entitycleanup.use";

        private PluginConfig config;
        private Timer cleanupTimer;
        private HashSet<string> targetNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        private bool running;

        private class PluginConfig
        {
            [JsonProperty("Auto Cleanup Enabled")]
            public bool AutoCleanupEnabled = false;

            [JsonProperty("Auto Cleanup Interval (Seconds)")]
            public float AutoCleanupInterval = 3600f;

            [JsonProperty("Minimum Entities To Start")]
            public int MinimumEntitiesToStart = 500;

            [JsonProperty("Warn Before Run")]
            public bool WarnBeforeRun = true;

            [JsonProperty("Warn Seconds Before")]
            public float WarnSecondsBefore = 30f;

            [JsonProperty("Broadcast to Players")]
            public bool BroadcastToPlayers = true;

            [JsonProperty("Protect Building Privilege")]
            public bool ProtectBuildingPrivilege = true;

            [JsonProperty("Require Authorization")]
            public bool RequireAuthorization = false;

            [JsonProperty("Skip Entities Owned By Players")]
            public bool SkipOwnedEntities = false;

            [JsonProperty("Entity Short Prefab Names")]
            public List<string> EntityShortPrefabNames = new List<string>
            {
                "sleepingbag",
                "campfire",
                "stash.small",
                "box.wooden",
                "lantern.deployed"
            };
        }

        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>
            {
                ["Removed"] = "Cleanup removed {0} entities ({1} checked, {2} protected). Took {3} ms.",
                ["Skipped"] = "Cleanup skipped: only {0} entities online, minimum is {1}.",
                ["Warn"] = "Entity cleanup starts in {0} seconds.",
                ["Running"] = "A cleanup pass is already running.",
                ["NoPermission"] = "You do not 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);
        }

        private void OnServerInitialized()
        {
            RebuildNameSet();

            if (!config.AutoCleanupEnabled) return;

            float interval = Mathf.Max(60f, config.AutoCleanupInterval);
            cleanupTimer = timer.Repeat(interval, 0, () =>
            {
                if (config.WarnBeforeRun && config.BroadcastToPlayers)
                    Server.Broadcast(Msg("Warn", null, Mathf.RoundToInt(config.WarnSecondsBefore)));

                if (config.WarnBeforeRun)
                    timer.Once(Mathf.Max(1f, config.WarnSecondsBefore), () => RunCleanup(null));
                else
                    RunCleanup(null);
            });
        }

        private void Unload()
        {
            cleanupTimer?.Destroy();
            cleanupTimer = null;
        }

        [ConsoleCommand("entitycleanup.run")]
        private void CmdRun(ConsoleSystem.Arg arg)
        {
            var player = arg.Player();
            if (player != null && !permission.UserHasPermission(player.UserIDString, PermUse))
            {
                arg.ReplyWith(Msg("NoPermission", player.UserIDString));
                return;
            }

            RunCleanup(arg);
        }

        private void RunCleanup(ConsoleSystem.Arg arg)
        {
            if (running)
            {
                arg?.ReplyWith(Msg("Running"));
                return;
            }

            int total = 0;
            foreach (var entity in BaseNetworkable.serverEntities)
            {
                if (entity != null) total++;
            }

            if (total < config.MinimumEntitiesToStart)
            {
                string skipped = Msg("Skipped", null, total, config.MinimumEntitiesToStart);
                arg?.ReplyWith(skipped);
                if (config.BroadcastToPlayers) Server.Broadcast(skipped);
                return;
            }

            RebuildNameSet();
            running = true;
            float started = UnityEngine.Time.realtimeSinceStartup;

            int checkedCount = 0;
            int removed = 0;
            int protectedCount = 0;

            foreach (var entry in BaseNetworkable.serverEntities)
            {
                var entity = entry as BaseNetworkable;
                if (entity == null || entity.IsDestroyed) continue;
                if (string.IsNullOrEmpty(entity.ShortPrefabName)) continue;
                if (!targetNames.Contains(entity.ShortPrefabName)) continue;

                checkedCount++;

                var owned = entity as BaseEntity;
                if (config.SkipOwnedEntities && owned != null && owned.OwnerID != 0) continue;

                if (config.ProtectBuildingPrivilege && IsProtected(entity))
                {
                    protectedCount++;
                    continue;
                }

                entity.Kill();
                removed++;
            }

            running = false;
            int elapsed = Mathf.RoundToInt((UnityEngine.Time.realtimeSinceStartup - started) * 1000f);

            string summary = Msg("Removed", null, removed, checkedCount, protectedCount, elapsed);
            Puts(summary);
            arg?.ReplyWith(summary);
            if (config.BroadcastToPlayers && arg == null) Server.Broadcast(summary);
        }

        private bool IsProtected(BaseNetworkable entity)
        {
            var entityBase = entity as BaseEntity;
            if (entityBase == null) return false;

            var cupboard = entityBase.GetBuildingPrivilege();
            if (cupboard == null) return false;
            if (!config.RequireAuthorization) return true;

            return cupboard.OwnerID == entityBase.OwnerID || cupboard.IsAuthed(entityBase as BasePlayer);
        }

        private void RebuildNameSet()
        {
            targetNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            if (config.EntityShortPrefabNames == null) return;

            foreach (var name in config.EntityShortPrefabNames)
            {
                if (!string.IsNullOrEmpty(name)) targetNames.Add(name.Trim());
            }
        }
    }
}

Hooks used: OnServerInitialized

Config file

Created automatically on first load at oxide/config/EntityCleanup.json.

EntityCleanup.json
{
  "Auto Cleanup Enabled": false,
  "Auto Cleanup Interval (Seconds)": 3600,
  "Minimum Entities To Start": 500,
  "Warn Before Run": true,
  "Warn Seconds Before": 30,
  "Broadcast to Players": true,
  "Protect Building Privilege": true,
  "Require Authorization": false,
  "Skip Entities Owned By Players": false,
  "Entity Short Prefab Names": [
    "sleepingbag",
    "campfire",
    "stash.small",
    "box.wooden",
    "lantern.deployed"
  ]
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
entitycleanup.runRuns a cleanup pass immediately and prints how many entities were removed and how many were skipped near a tool cupboard.entitycleanup.use
Permissions
PermissionGrants
entitycleanup.useGrants access to the entitycleanup.run console command when the caller is not a server admin.

How the code works

  1. Configuring which entities are targeted

    The config holds a Entity Short Prefab Names list. Each entry is compared case-insensitively against the ShortPrefabName of a BaseNetworkable from BaseNetworkable.serverEntities. Short prefab names are the asset names without the path or .prefab suffix, for example sleepingbag, campfire, box.wooden or stash.small. If a name is misspelled the plugin simply matches nothing, so check the list against what actually spawns on your server before enabling the timer.

  2. Scanning the network entity list safely

    The scan iterates BaseNetworkable.serverEntities, casts each entry with as BaseNetworkable, and skips anything null, already destroyed (IsDestroyed), or with an empty ShortPrefabName. It then checks the prefab name against a HashSet<string> built from the config so the comparison does not allocate in the loop. This runs on the main thread, which is required because Kill() must not be called from a background thread.

  3. Skipping entities inside tool cupboard range

    Protection uses GetBuildingPrivilege() on the entity. When the entity is inside a building privilege area, Rust returns the BuildingPrivlidge covering it. The config has two switches: Protect Building Privilege turns the check on, and Require Authorization decides whether the entity is skipped only when its OwnerID is authorized on that cupboard. With authorization required, a stray bag next to a cupboard that has no matching owner is still removed.

  4. The console command and permission

    entitycleanup.run is a [ConsoleCommand] method taking ConsoleSystem.Arg. When arg.Player() is null the command came from the server console or RCON and runs without a permission check; otherwise permission.UserHasPermission is checked against entitycleanup.use. Results go back through arg.ReplyWith, so RCON clients see the summary too. Chat output to online admins is optional and controlled by Broadcast to Players.

  5. The timed job

    When Auto Cleanup Enabled is true, OnServerInitialized starts a timer.Repeat with the configured interval in seconds. The timer reference is stored in a field and destroyed in Unload. Minimum Entities To Start avoids pointless scans on a fresh wipe: if the total network entity count is below that value the pass is skipped. Warn Before Run sends a chat message a few seconds ahead so players can finish what they are doing.

  6. Reporting and protecting player deployables

    Every pass records checked, removed, skipped and elapsed milliseconds and sends the summary through the language file, so you can translate the output. Skip Entities Owned By Players keeps deployables with a non-zero OwnerID alive even outside cupboard range, which is a good default if you only want to catch truly abandoned items. Set it to false when you want a stricter cleanup.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip:oxide.grant group default entitycleanup.use c.grant group default entitycleanup.use # Carbon

Variations

Each one opens the generator with a ready prompt you can tweak.

  • Dry run mode

    Log what would be removed without actually killing anything, useful before the first live pass.

    Generate this version
  • Per-type age limit

    Only remove entities older than a configured number of minutes.

    Generate this version
  • Scheduled warning plus countdown

    Announce the cleanup with a countdown and let players opt out of the message.

    Generate this version

FAQ

Where do I find the short prefab name for an entity?

Spawn the entity and read ShortPrefabName on the BaseNetworkable. Several admin mods and the server's own entity dumps print it. Common ones: sleepingbag, campfire, box.wooden, stash.small, lantern.deployed. Names are case-insensitive in the config but the casing must otherwise match, including dots.

Will this remove entities inside player bases?

Not when Protect Building Privilege is true and the base has a tool cupboard. Any entity whose GetBuildingPrivilege() returns a cupboard is skipped, or skipped only if authorized depending on Require Authorization. Bases without a cupboard, or entities far from one, are eligible for removal.

Does the cleanup cause lag?

It runs on the main thread and touches every network entity once. On a large server that is a short spike, usually well under a second for tens of thousands of entities, since killing is deferred by the game. Run it during low population hours or on a longer interval if you see hitches.

How do I install it on Oxide or Carbon?

Drop the compiled .cs file into oxide/plugins on Oxide or carbon/plugins on Carbon and let it compile. Edit oxide/config/EntityCleanup.json to set the prefab list, then reload the plugin. Carbon loads the same Oxide plugin file unchanged.

Why did my pass remove nothing?

Check that Auto Cleanup Enabled and the manual command both build the same list, that Minimum Entities To Start is below the current entity count, and that Skip Entities Owned By Players is not hiding everything. Also verify the prefab names, a single typo removes that type from the match list.

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 called EntityCleanup with a configurable list of short prefab names, protect entities inside tool cupboard range, an entitycleanup.run console command and a repeating timer that reports removed counts in chat and console.
Customize for my server

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

Last updated Oct 26, 2026.

Customize entity cleanup

Customize