Skip to content
RustPluginGenerator

How to make a Rust NPC loot lock plugin (owner + team only)

Locks NPC corpses to the killer and their team for a configurable number of seconds after death, then opens them to everyone. Uses OnEntityDeath to record ownership and CanLootEntity to gate looting.

On busy PvE and hybrid servers, other players hover around scientist and animal kills and grab the loot the moment the corpse drops. The fastest fix is server-side: remember who killed the NPC, then deny loot access to anyone else until a timer runs out. This plugin does exactly that without touching the corpse inventory itself, so nothing is lost if the server restarts. It hooks OnEntityDeath to capture the killer, the time of death and the killer's team, and CanLootEntity to decide whether the looting player is allowed in. The default lock lasts 60 seconds and covers ScientistNPC, animals, Bradley APC and patrol helicopters. Team members of the killer are allowed through, so grouped players are not locked out of their own kills. After the window closes, the corpse behaves like normal Rust loot and anyone can open it. Everything is configurable: duration, which NPC types are covered, and the message the blocked player sees.

The plugin code

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

namespace Oxide.Plugins
{
    [Info("NpcLootLock", "RustPluginGenerator", "1.0.0")]
    [Description("Locks NPC corpses to the killer and their team for a configurable duration.")]
    public class NpcLootLock : RustPlugin
    {
        private const string PermUse = "npclootlock.use";
        private const string PermBypass = "npclootlock.bypass";

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

        private class LockEntry
        {
            public ulong KillerId;
            public ulong TeamId;
            public DateTime KillTime;
        }

        private class PluginConfig
        {
            [JsonProperty("Lock Seconds")]
            public int LockSeconds = 60;

            [JsonProperty("Locked NPC Types")]
            public List<string> LockedNpcTypes = new List<string>
            {
                "scientistnpc",
                "scientistnpc_heavy",
                "scientistnpc_arcade",
                "scientistnpc_vehicle",
                "bear",
                "polarbear",
                "wolf",
                "boar",
                "stag",
                "chicken",
                "bradleyapc",
                "patrolhelicopter"
            };

            [JsonProperty("Cleanup Interval Seconds")]
            public int CleanupIntervalSeconds = 30;
        }

        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>
            {
                ["loot_blocked"] = "This corpse is locked. Unlocks in {0}s.",
                ["info_locked"] = "Locked for {0} more seconds.",
                ["info_unlocked"] = "This corpse is not locked.",
                ["no_corpse"] = "Look at a locked corpse to see its lock timer."
            }, 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(PermBypass, this);
        }

        private void OnServerInitialized()
        {
            if (config.LockSeconds <= 0) return;
            cleanupTimer = timer.Every(Mathf.Max(5, config.CleanupIntervalSeconds), CleanupExpired);
        }

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

        private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
        {
            if (entity == null || info == null) return;
            if (config.LockSeconds <= 0) return;

            if (!IsLockableNpc(entity)) return;

            var killer = info.InitiatorPlayer;
            if (killer == null || killer.IsNpc || killer.IsDead()) return;

            // The lootable corpse is created shortly after death; map it via the death position.
            var container = entity as LootableCorpse;
            BaseEntity lootTarget = container;

            if (lootTarget == null)
            {
                var netId = entity.net?.ID.Value ?? 0;
                if (netId == 0) return;

                NextTick(() =>
                {
                    // Try to find the spawned corpse owned by this entity near its death position.
                    var center = entity.transform.position;
                    LootableCorpse found = null;
                    foreach (var b in BaseNetworkable.serverEntities)
                    {
                        var c = b as LootableCorpse;
                        if (c == null) continue;
                        if (Vector3.Distance(c.transform.position, center) > 2f) continue;
                        found = c;
                        break;
                    }
                    if (found == null) return;

                    var corpseId = found.net.ID.Value;
                    locks[corpseId] = new LockEntry
                    {
                        KillerId = killer.userID.Get(),
                        TeamId = killer.currentTeam,
                        KillTime = DateTime.UtcNow
                    };
                });
                return;
            }

            var targetId = lootTarget.net.ID.Value;
            locks[targetId] = new LockEntry
            {
                KillerId = killer.userID.Get(),
                TeamId = killer.currentTeam,
                KillTime = DateTime.UtcNow
            };
        }

        private object CanLootEntity(BasePlayer player, StorageContainer storageContainer)
        {
            if (player == null || storageContainer == null) return null;
            if (config.LockSeconds <= 0) return null;

            var netId = storageContainer.net?.ID.Value ?? 0;
            if (netId == 0) return null;

            LockEntry entry;
            if (!locks.TryGetValue(netId, out entry)) return null;

            var remaining = config.LockSeconds - (DateTime.UtcNow - entry.KillTime).TotalSeconds;
            if (remaining <= 0)
            {
                locks.Remove(netId);
                return null;
            }

            if (permission.UserHasPermission(player.UserIDString, PermBypass)) return null;

            var playerId = player.userID.Get();
            if (playerId == entry.KillerId) return null;
            if (entry.TeamId != 0 && player.currentTeam == entry.TeamId) return null;

            player.ChatMessage(Msg("loot_blocked", player.UserIDString, Mathf.CeilToInt((float)remaining)));
            return true;
        }

        private void OnEntityKill(BaseNetworkable networkable)
        {
            if (networkable == null) return;
            var netId = networkable.net?.ID.Value ?? 0;
            if (netId == 0) return;
            locks.Remove(netId);
        }

        [ChatCommand("npclock")]
        private void CmdNpcLock(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                player.ChatMessage("You do not have permission to use this command.");
                return;
            }

            var ray = new Ray(player.eyes.position, player.eyes.HeadForward());
            RaycastHit hit;
            if (!Physics.Raycast(ray, out hit, 5f))
            {
                player.ChatMessage(Msg("no_corpse", player.UserIDString));
                return;
            }

            var lootable = hit.GetEntity() as LootableCorpse;
            if (lootable == null)
            {
                player.ChatMessage(Msg("no_corpse", player.UserIDString));
                return;
            }

            LockEntry entry;
            if (!locks.TryGetValue(lootable.net.ID.Value, out entry))
            {
                player.ChatMessage(Msg("info_unlocked", player.UserIDString));
                return;
            }

            var remaining = config.LockSeconds - (DateTime.UtcNow - entry.KillTime).TotalSeconds;
            if (remaining <= 0)
            {
                player.ChatMessage(Msg("info_unlocked", player.UserIDString));
                locks.Remove(lootable.net.ID.Value);
                return;
            }

            player.ChatMessage(Msg("info_locked", player.UserIDString, Mathf.CeilToInt((float)remaining)));
        }

        private void CleanupExpired()
        {
            if (locks.Count == 0) return;
            var cutoff = DateTime.UtcNow.AddSeconds(-config.LockSeconds * 2);
            var stale = new List<ulong>();
            foreach (var kvp in locks)
            {
                if (kvp.Value.KillTime < cutoff) stale.Add(kvp.Key);
            }
            foreach (var id in stale) locks.Remove(id);
        }

        private bool IsLockableNpc(BaseCombatEntity entity)
        {
            if (entity == null) return false;
            if (entity is LootableCorpse) return false;
            if (!(entity is ScientistNPC) && !(entity is BaseAnimalNPC) &&
                !(entity is BradleyAPC) && !(entity is BaseHelicopter)) return false;

            var shortName = entity.ShortPrefabName ?? "";
            if (shortName.EndsWith(".prefab")) shortName = shortName.Substring(0, shortName.Length - 7);
            if (config.LockedNpcTypes.Count == 0) return true;
            return config.LockedNpcTypes.Exists(t => shortName.StartsWith(t, StringComparison.OrdinalIgnoreCase));
        }
    }
}

Hooks used: OnServerInitialized, OnEntityDeath, CanLootEntity, OnEntityKill

Config file

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

NpcLootLock.json
{
  "Lock Seconds": 60,
  "Locked NPC Types": [
    "scientistnpc",
    "scientistnpc_heavy",
    "scientistnpc_arcade",
    "scientistnpc_vehicle",
    "bear",
    "polarbear",
    "wolf",
    "boar",
    "stag",
    "chicken",
    "bradleyapc",
    "patrolhelicopter"
  ],
  "Cleanup Interval Seconds": 30
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/npclock infoShows the remaining lock time on the corpse you are looking at.npclootlock.use
Permissions
PermissionGrants
npclootlock.useGrants access to the /npclock info chat command.
npclootlock.bypassPlayer can loot any locked NPC corpse regardless of ownership.

How the code works

  1. Record the killer in OnEntityDeath

    OnEntityDeath(BaseCombatEntity entity, HitInfo info) fires for every death. The plugin ignores anything that is not an NPC by checking the entity type against the configurable list: ScientistNPC, BaseAnimalNPC, BradleyAPC and any BaseHelicopter. If it is a LootableCorpse or other lootable container in the NPC family, the plugin stores the corpse's network id, the killer's userID, the killer's team id (player.currentTeam) and DateTime.UtcNow. Ownership lives in a dictionary keyed by the corpse net id, so lookups in CanLootEntity are O(1).

  2. Gate looting with CanLootEntity

    object CanLootEntity(BasePlayer player, StorageContainer storageContainer) is called when a player opens a lootable container. Returning a non-null value blocks the interaction. The plugin resolves the container's net.ID.Value, looks it up in the ownership dictionary, and compares the player's userID against the stored killer id and team id. Admins with npclootlock.bypass and the killer themselves are always allowed. If the entry exists but the lock time has expired, the plugin removes the entry and lets the loot through, so stale state cannot grow forever.

  3. Team membership check

    Rust teams are tracked by player.currentTeam (a ulong, 0 means no team). At the moment of death the plugin records the killer's team. When another player tries to loot, the plugin checks player.currentTeam != 0 && player.currentTeam == storedTeam. That covers the common case where a teammate finishes the kill or runs over to pick up loot. Players who join the team after the kill are also allowed, which matches how most admins expect teams to work.

  4. Handle corpse despawn

    When a lootable corpse is killed or removed, the ownership entry is dead weight. The plugin subscribes to OnEntityKill and removes any matching entry. A periodic cleanup timer (timer.Every) also drops entries whose age exceeds Lock Seconds * 2 so the dictionary cannot grow on servers with high NPC throughput. This runs on the server tick and is cheap because the dictionary only holds entries for recently killed NPCs.

  5. Configuring which NPCs are locked

    The Locked NPC Types list in the config holds prefab base names. entity.ShortPrefabName is checked against that list. Add entries like scientistnpc_heavy or bear to narrow or widen the scope. Setting Lock Seconds to 0 disables the lock entirely; setting it to a high value effectively makes corpses private for their lifetime. The message shown to blocked players is the loot_blocked lang string and supports {0} for the remaining seconds.

  6. Remaining-time message

    When a blocked player is denied, the plugin sends the loot_blocked message through SendReply with the current remaining seconds. The same value is used by /npclock info for players with npclootlock.use, which is handy for bug reports and for players who want to know how long until a corpse opens. No Puts or PrintWarning calls happen inside CanLootEntity because the hook fires on every loot attempt and chat spam there is expensive.

Install it on your server

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

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

Variations

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

  • Only lock the killer, ignore team

    Strip the team check so only the killing player can loot for the lock duration.

    Generate this version
  • Lock airdrop and supply crates too

    Extend the ownership tracking to airdrops and heli crates so the caller is the only one who can open them briefly.

    Generate this version
  • Show a lock timer above locked corpses

    Draw a small floating CUI label above locked corpses so players can see when they unlock.

    Generate this version

FAQ

Does the lock survive a server restart?

No. Ownership is stored in memory only. After a restart the plugin treats all existing corpses as unlocked, which matches vanilla behavior and avoids blocking loot from corpses that were already partially opened.

Does this work with LootableCorpse player corpses?

No, player corpses are not in the configured NPC list, so they are ignored. CanLootEntity is only called for containers the plugin knows about, and the ownership entry only exists for NPC deaths. If you want player-death loot lock, that is a separate plugin.

Will this block admins from investigating?

No. Anyone with the npclootlock.bypass permission ignores the lock everywhere. Admins have every Oxide permission by default, so this works without extra setup once the plugin registers the permission.

How do I install it on Oxide or Carbon?

Drop the compiled .cs file into oxide/plugins/. Reload the plugin with oxide.reload NpcLootLock, then edit oxide/config/NpcLootLock.json and reload again. Carbon loads the same file from its plugins directory without changes.

Why is my custom NPC type not locked?

The plugin matches against entity.ShortPrefabName, which is the prefab name without the .prefab extension. Use the exact prefab name (for example scientistnpc_heavy) in the Locked NPC Types list. Check the console with oxide.reload NpcLootLock after editing the config.

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 that locks NPC corpses to the killer and their team for 60 seconds using OnEntityDeath and CanLootEntity, with configurable duration, NPC type list, bypass permission and a blocked message.
Customize for my server

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

Last updated Oct 26, 2026.

Customize npc corpse loot lock

Customize