Skip to content
RustPluginGenerator

How to make a Rust corpse loot protection plugin (solo and team lock)

Locks a dead player's corpse and dropped backpack to the owner and their current team for a configurable number of minutes. It watches spawned containers with OnEntitySpawned and blocks other players in CanLootEntity, with a permission-based admin bypass.

On a PvE or semi-PvE server the biggest complaint is corpse theft: someone dies, a stranger sprints in and takes everything before the owner respawns and walks back. Rust does not ship a way to lock a body or a backpack, and the corpse is a normal LootableCorpse while the backpack is a DroppedItemContainer, so you have to treat both.

This plugin records the owner of each new corpse and dropped backpack through OnEntitySpawned, stores the owner's team at spawn time, and starts a countdown from your config. While the lock is active, CanLootEntity returns false for anyone who is not the owner, not on that team, and not holding the bypass permission. After the timer expires the container becomes public again. Owners and teammates always get in, so nobody is ever locked out of their own loot.

The plugin code

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

namespace Oxide.Plugins
{
    [Info("Corpse Loot Protection", "RustPluginGenerator", "1.0.0")]
    [Description("Locks player corpses and dropped backpacks to the owner and their team for a configurable time.")]
    public class CorpseLootProtection : RustPlugin
    {
        private const string PermAdmin = "corpselootprotection.admin";
        private const string PermUse = "corpselootprotection.use";

        private class LockEntry
        {
            public ulong OwnerId;
            public ulong TeamId;
            public int ExpiresAt;
        }

        private class PluginConfig
        {
            [JsonProperty("Lock duration in seconds")]
            public int LockSeconds = 300;

            [JsonProperty("Share lock with owner's team")]
            public bool TeamShared = true;

            [JsonProperty("Lock player corpses")]
            public bool LockPlayerCorpses = true;

            [JsonProperty("Lock dropped backpacks")]
            public bool LockDroppedBackpacks = true;

            [JsonProperty("Show remaining time in refusal message")]
            public bool ShowRemainingTime = true;

            [JsonProperty("Refusal message cooldown in seconds")]
            public float MessageCooldown = 5f;
        }

        private PluginConfig config;
        private readonly Dictionary<ulong, LockEntry> locks = new Dictionary<ulong, LockEntry>();
        private readonly Dictionary<ulong, float> lastMessage = new Dictionary<ulong, float>();
        private Timer cleanupTimer;

        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>
            {
                ["Locked"] = "This container is locked to another player for {0} more seconds.",
                ["LockedShort"] = "This container is locked to another player.",
                ["NotYours"] = "This is not your container.",
                ["Status"] = "This container is locked to its owner for {0} more seconds.",
                ["NoLock"] = "This container is not locked.",
                ["UnlockedAll"] = "All corpse locks were cleared ({0} entries removed).",
                ["NoPermission"] = "You do not have permission to use this command."
            }, this);
        }

        private string Msg(string key, string userId = null, params object[] args)
        {
            return string.Format(lang.GetMessage(key, this, userId), args);
        }

        private void Init()
        {
            permission.RegisterPermission(PermAdmin, this);
            permission.RegisterPermission(PermUse, this);
        }

        private void OnServerInitialized()
        {
            LoadLocks();
            cleanupTimer = timer.Every(30f, Cleanup);
        }

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

        private void LoadLocks()
        {
            try
            {
                var stored = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<ulong, LockEntry>>(Name);
                if (stored == null) return;
                var now = Facepunch.Math.Epoch.Current;
                foreach (var pair in stored)
                {
                    if (pair.Value == null || pair.Value.ExpiresAt <= now) continue;
                    if (!(BaseNetworkable.serverEntities.Find(new NetworkableId(pair.Key)) is BaseEntity)) continue;
                    locks[pair.Key] = pair.Value;
                }
            }
            catch (Exception ex)
            {
                PrintWarning("Could not load stored locks: " + ex.Message);
            }
        }

        private void SaveLocks()
        {
            Interface.Oxide.DataFileSystem.WriteObject(Name, locks);
        }

        private void OnEntitySpawned(BaseNetworkable entity)
        {
            if (entity == null || entity.IsDestroyed) return;

            var corpse = entity as LootableCorpse;
            if (corpse != null)
            {
                if (!config.LockPlayerCorpses) return;
                if (corpse.playerSteamID == 0) return;
                CreateLock(entity, corpse.playerSteamID);
                return;
            }

            var dropped = entity as DroppedItemContainer;
            if (dropped != null)
            {
                if (!config.LockDroppedBackpacks) return;
                if (dropped.playerSteamID == 0) return;
                CreateLock(entity, dropped.playerSteamID);
            }
        }

        private void CreateLock(BaseNetworkable entity, ulong ownerId)
        {
            if (config.LockSeconds <= 0) return;
            var id = entity.net.ID.Value;
            if (locks.ContainsKey(id)) return;

            ulong teamId = 0;
            var owner = BasePlayer.FindByID(ownerId);
            if (owner != null && owner.currentTeam != 0) teamId = owner.currentTeam;

            locks[id] = new LockEntry
            {
                OwnerId = ownerId,
                TeamId = teamId,
                ExpiresAt = Facepunch.Math.Epoch.Current + config.LockSeconds
            };
        }

        private void Cleanup()
        {
            if (locks.Count == 0) return;
            var now = Facepunch.Math.Epoch.Current;
            var remove = new List<ulong>();
            foreach (var pair in locks)
            {
                if (pair.Value == null || pair.Value.ExpiresAt <= now) remove.Add(pair.Key);
            }
            foreach (var id in remove) locks.Remove(id);
        }

        private object CanLootEntity(BasePlayer player, LootableCorpse corpse)
        {
            if (player == null || corpse == null) return null;
            return CheckAccess(player, corpse.net.ID.Value);
        }

        private object CanLootEntity(BasePlayer player, DroppedItemContainer container)
        {
            if (player == null || container == null) return null;
            return CheckAccess(player, container.net.ID.Value);
        }

        private object CanLootEntity(BasePlayer player, StorageContainer container)
        {
            if (player == null || container == null) return null;
            return CheckAccess(player, container.net.ID.Value);
        }

        private object CheckAccess(BasePlayer player, ulong entityId)
        {
            LockEntry entry;
            if (!locks.TryGetValue(entityId, out entry) || entry == null) return null;
            if (entry.ExpiresAt <= Facepunch.Math.Epoch.Current)
            {
                locks.Remove(entityId);
                return null;
            }

            if (permission.UserHasPermission(player.UserIDString, PermAdmin)) return null;
            if (player.userID.Get() == entry.OwnerId) return null;
            if (config.TeamShared && entry.TeamId != 0 && player.currentTeam == entry.TeamId) return null;

            NotifyLocked(player, entry);
            return false;
        }

        private void NotifyLocked(BasePlayer player, LockEntry entry)
        {
            float now = UnityEngine.Time.realtimeSinceStartup;
            float last;
            if (lastMessage.TryGetValue(player.userID.Get(), out last) && now - last < config.MessageCooldown) return;
            lastMessage[player.userID.Get()] = now;

            int remaining = Mathf.Max(0, entry.ExpiresAt - Facepunch.Math.Epoch.Current);
            if (config.ShowRemainingTime)
                player.ChatMessage(Msg("Locked", player.UserIDString, remaining));
            else
                player.ChatMessage(Msg("LockedShort", player.UserIDString));
        }

        [ChatCommand("corpselock")]
        private void CmdCorpseLock(BasePlayer player, string command, string[] args)
        {
            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                player.ChatMessage(Msg("NoPermission", player.UserIDString));
                return;
            }

            BaseEntity found = FindLookingAt(player);
            if (found == null)
            {
                player.ChatMessage(Msg("NotYours", player.UserIDString));
                return;
            }

            LockEntry entry;
            if (!locks.TryGetValue(found.net.ID.Value, out entry) || entry == null || entry.ExpiresAt <= Facepunch.Math.Epoch.Current)
            {
                player.ChatMessage(Msg("NoLock", player.UserIDString));
                return;
            }

            if (player.userID.Get() != entry.OwnerId &&
                !(config.TeamShared && entry.TeamId != 0 && player.currentTeam == entry.TeamId) &&
                !permission.UserHasPermission(player.UserIDString, PermAdmin))
            {
                player.ChatMessage(Msg("NotYours", player.UserIDString));
                return;
            }

            int remaining = Mathf.Max(0, entry.ExpiresAt - Facepunch.Math.Epoch.Current);
            player.ChatMessage(Msg("Status", player.UserIDString, remaining));
        }

        private BaseEntity FindLookingAt(BasePlayer player)
        {
            RaycastHit hit;
            if (!Physics.Raycast(player.eyes.HeadRay(), out hit, 5f, Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore))
                return null;
            var entity = hit.GetEntity();
            if (entity is LootableCorpse || entity is DroppedItemContainer || entity is StorageContainer)
                return entity;
            return null;
        }

        [ConsoleCommand("corpselootprotection.unlock")]
        private void CcmdUnlockAll(ConsoleSystem.Arg arg)
        {
            var player = arg.Player();
            if (player != null && !permission.UserHasPermission(player.UserIDString, PermAdmin))
            {
                arg.ReplyWith(Msg("NoPermission", player.UserIDString));
                return;
            }

            int count = locks.Count;
            locks.Clear();
            SaveLocks();
            arg.ReplyWith(Msg("UnlockedAll", null, count));
        }
    }
}

Hooks used: OnServerInitialized, OnEntitySpawned, CanLootEntity

Config file

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

CorpseLootProtection.json
{
  "Lock duration in seconds": 300,
  "Share lock with owner's team": true,
  "Lock player corpses": true,
  "Lock dropped backpacks": true,
  "Show remaining time in refusal message": true,
  "Refusal message cooldown in seconds": 5
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/corpselockShows the remaining lock time on the corpse, backpack or container you are looking at.corpselootprotection.use
corpselootprotection.unlockConsole command that clears every active corpse lock immediately.corpselootprotection.admin
Permissions
PermissionGrants
corpselootprotection.adminBypass every corpse lock and use the unlock console command.
corpselootprotection.useAllows the /corpselock chat command that shows remaining lock time.

How the code works

  1. Record the owner when a corpse or backpack spawns

    OnEntitySpawned fires for every networked entity, so the first thing the hook does is test the type. A player death produces a LootableCorpse whose playerSteamID is the dead player's id, and a dropped backpack is a DroppedItemContainer whose playerSteamID is the owner. Both get a LockEntry keyed by net.ID.Value, the network id, which is stable for the lifetime of the entity. Entities with a zero owner id (NPC corpses, loot bags spawned by plugins) are skipped.

  2. Capture the team at spawn time

    When the lock is created the plugin reads BasePlayer.FindByID(ownerId) and stores owner.currentTeam in the entry. Storing the team id once means a teammate who joins later cannot loot an old corpse, and a teammate who was removed keeps access only if they were on the team at death. If currentTeam is 0 the stored TeamId stays 0 and the lock is solo-only. The expiry is Facepunch.Math.Epoch.Current + config.LockSeconds, written to the entry's ExpiresAt field.

  3. Block looting in CanLootEntity

    Three overloads matter here: CanLootEntity(BasePlayer, LootableCorpse), CanLootEntity(BasePlayer, DroppedItemContainer) and the generic CanLootEntity(BasePlayer, StorageContainer), which covers chests someone may stuff a body into. Each one calls CheckAccess, which looks up the entry by network id, checks ExpiresAt, then allows the owner (player.userID equals OwnerId), any player whose currentTeam matches TeamId and TeamShared is true, and anyone with corpselootprotection.admin. Returning false cancels the loot. Everything else returns null so default behavior continues.

  4. Clean up expired records

    A repeating timer runs every 30 seconds and drops entries whose ExpiresAt is in the past, so the dictionary does not grow forever on a busy server. Removing an entry does not change gameplay: once the lock is gone the container is a normal corpse and anyone can loot it. The same expiry check also runs inside CheckAccess, so a refused or allowed lookup on a stale entry removes it immediately. The timer reference is kept in cleanupTimer and destroyed in Unload.

  5. Persist locks across restarts

    Corpses do not survive a restart in practice, but reloading the plugin during a fight should not hand bodies to strangers. LoadLocks reads a Dictionary<ulong, LockEntry> from oxide/data/CorpseLootProtection.json through Interface.Oxide.DataFileSystem.ReadObject, keeps entries whose ExpiresAt is still in the future and whose entity id still resolves in BaseNetworkable.serverEntities. SaveLocks writes the whole dictionary back, and Unload saves on the way out.

  6. Feedback, bypass and the chat command

    When a loot attempt is refused, NotifyLocked sends a language message with the remaining seconds if ShowRemainingTime is true, otherwise a shorter one. It is rate-limited per player with the MessageCooldown config value (default 5 seconds) using UnityEngine.Time.realtimeSinceStartup, so interaction spam cannot flood chat. /corpselock raycasts 5 meters from player.eyes.HeadRay() for a corpse, backpack or container and reports the remaining lock time to the owner or a teammate.

  7. Configuration and tuning

    Lock duration in seconds sets the lock length (default 300). Share lock with owner's team switches team access on or off. Lock player corpses and Lock dropped backpacks let admins protect the body and the backpack separately, so a server can keep backpacks private while leaving bodies public. Show remaining time in refusal message and Refusal message cooldown in seconds control what a blocked player sees and how often. A missing or broken config file falls back to defaults and is rewritten.

Install it on your server

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

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

Variations

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

  • Lock only the backpack, release the corpse immediately

    On some servers the corpse should be public but the backpack kept private so the owner can retrieve the loot.

    Generate this version
  • Extend the lock every time the owner is hit

    Punishes griefers by refreshing the timer when someone attacks the owner nearby.

    Generate this version
  • Broadcast a warning when someone tries to loot a blocked corpse

    Makes it obvious why a player cannot open the container.

    Generate this version

FAQ

Does this work with Carbon?

Yes. Carbon loads the same Oxide plugin file, so the layout and the hooks are unchanged. Drop it in oxide/plugins and it will be loaded on the next reload or restart.

What happens to a corpse if the owner never comes back?

The lock expires after the configured duration and the corpse becomes public like any other. The entity itself despawns when Rust decides it has rotted, which the plugin does not touch.

Can I lock only my own corpse and not my team's?

Set Share lock with owner's team to false in the config. The plugin still stores the team id at spawn time but ignores it in the access check, so only the owner is allowed to loot.

How does the plugin know a container is a corpse?

It matches on the LootableCorpse and DroppedItemContainer types in OnEntitySpawned. Chests, lockers and other storage are not locked on spawn, though the generic CanLootEntity(StorageContainer) overload still checks the lock table in case a corpse id collides in a moved container.

Will a player with the bypass permission still get the refusal message?

No. The bypass check runs before the message is sent, so admins and moderators can loot freely without spam. Everything still goes through the language file if you want to change the wording.

Customize it for your server

Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.

Prompt: Make a Rust Oxide plugin that locks player corpses and dropped backpacks to the owner and their team for 300 seconds, with a permission-based admin bypass and a /corpselock chat command showing remaining time.
Customize for my server

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

Last updated Oct 26, 2026.

Customize player corpse loot protection

Customize