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
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.
{
"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
| Command | What it does | Permission |
|---|---|---|
/npclock info | Shows the remaining lock time on the corpse you are looking at. | npclootlock.use |
| Permission | Grants |
|---|---|
npclootlock.use | Grants access to the /npclock info chat command. |
npclootlock.bypass | Player can loot any locked NPC corpse regardless of ownership. |
How the code works
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,BradleyAPCand anyBaseHelicopter. If it is aLootableCorpseor 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) andDateTime.UtcNow. Ownership lives in a dictionary keyed by the corpse net id, so lookups inCanLootEntityare O(1).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'snet.ID.Value, looks it up in the ownership dictionary, and compares the player's userID against the stored killer id and team id. Admins withnpclootlock.bypassand 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.Team membership check
Rust teams are tracked by
player.currentTeam(aulong, 0 means no team). At the moment of death the plugin records the killer's team. When another player tries to loot, the plugin checksplayer.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.Handle corpse despawn
When a lootable corpse is killed or removed, the ownership entry is dead weight. The plugin subscribes to
OnEntityKilland removes any matching entry. A periodic cleanup timer (timer.Every) also drops entries whose age exceedsLock Seconds * 2so 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.Configuring which NPCs are locked
The
Locked NPC Typeslist in the config holds prefab base names.entity.ShortPrefabNameis checked against that list. Add entries likescientistnpc_heavyorbearto narrow or widen the scope. SettingLock Secondsto0disables the lock entirely; setting it to a high value effectively makes corpses private for their lifetime. The message shown to blocked players is theloot_blockedlang string and supports{0}for the remaining seconds.Remaining-time message
When a blocked player is denied, the plugin sends the
loot_blockedmessage throughSendReplywith the current remaining seconds. The same value is used by/npclock infofor players withnpclootlock.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 insideCanLootEntitybecause the hook fires on every loot attempt and chat spam there is expensive.
Install it on your server
Save NpcLootLock.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/NpcLootLock.json(Carbon:carbon/configs/NpcLootLock.json). After editing, reload it:oxide.reload NpcLootLock c.reload NpcLootLock # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip: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 versionLock 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 versionShow 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.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More pve plugins
- Building damage protectionBlocks and deployables only take damage from their owner or players authorized on the covering tool cupboard.OnEntityTakeDamage
- Player corpse loot protectionLocks a dead player's corpse and dropped backpack to the owner and their current team for a configurable number of minutes.OnServerInitializedOnEntitySpawnedCanLootEntity
- Heli and Bradley loot lockTracks damage dealt to the Patrol Helicopter and Bradley APC per player and locks the crates they drop for a configurable time, allowing only theโฆOnServerInitializedOnServerSaveOnEntityTakeDamage
Last updated Oct 26, 2026.
Customize npc corpse loot lock
Customize