How to make a Rust custom NPC loot plugin (corpse loot tables)
Rewrites the items inside corpses left by configured NPCs, using a weighted per-prefab loot table. It uses the OnCorpsePopulate hook, which fires before the corpse's container is filled.
Scientist corpses on most servers are predictable: the same few items, the same odds, and players learn to ignore them. If you want NPC kills to feed your economy or progression, you need control over what drops and how often. The OnCorpsePopulate hook fires when a corpse entity is created from a dead NPC and hands you both the NPC that died and the corpse that will hold the loot. Returning null keeps Rust's default behaviour, so the plugin only touches prefabs you list in its config.
The plugin below keeps a weighted table per NPC short prefab name (scientistnpc_roam, scientistnpc_heavy, and so on). For matching corpses it empties the NPC's main, wear and belt inventory containers, then rolls entries until the configured item count is reached and moves the rolled items into those containers. Unknown shortnames are reported when the plugin builds its tables, not mid-fight.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("NpcCorpseLoot", "RustPluginGenerator", "1.0.0")]
[Description("Replaces corpse loot for configured NPC prefabs with weighted loot tables.")]
public class NpcCorpseLoot : RustPlugin
{
private const string PermAdmin = "npccorpseloot.admin";
private PluginConfig config;
private readonly Dictionary<string, LootTable> tables = new Dictionary<string, LootTable>();
private class LootEntry
{
[JsonProperty("Shortname")]
public string Shortname = "scrap";
[JsonProperty("MinAmount")]
public int MinAmount = 1;
[JsonProperty("MaxAmount")]
public int MaxAmount = 1;
[JsonProperty("Weight")]
public float Weight = 10f;
}
private class LootTable
{
[JsonProperty("MinItems")]
public int MinItems = 3;
[JsonProperty("MaxItems")]
public int MaxItems = 5;
[JsonProperty("Entries")]
public List<LootEntry> Entries = new List<LootEntry>();
}
private class PluginConfig
{
[JsonProperty("Loot tables (short prefab name -> table)")]
public Dictionary<string, LootTable> LootTables = new Dictionary<string, LootTable>
{
["scientistnpc_roam"] = new LootTable
{
MinItems = 3,
MaxItems = 5,
Entries = new List<LootEntry>
{
new LootEntry { Shortname = "scrap", MinAmount = 20, MaxAmount = 60, Weight = 30f },
new LootEntry { Shortname = "syringe.medical", MinAmount = 1, MaxAmount = 1, Weight = 20f },
new LootEntry { Shortname = "ammo.rifle", MinAmount = 10, MaxAmount = 15, Weight = 25f },
new LootEntry { Shortname = "rifle.ak", MinAmount = 1, MaxAmount = 1, Weight = 1f }
}
},
["scientistnpc_heavy"] = new LootTable
{
MinItems = 4,
MaxItems = 6,
Entries = new List<LootEntry>
{
new LootEntry { Shortname = "scrap", MinAmount = 50, MaxAmount = 120, Weight = 20f },
new LootEntry { Shortname = "ammo.rifle.hv", MinAmount = 10, MaxAmount = 20, Weight = 20f },
new LootEntry { Shortname = "weapon.mod.holosight", MinAmount = 1, MaxAmount = 1, Weight = 5f },
new LootEntry { Shortname = "rifle.l96", MinAmount = 1, MaxAmount = 1, Weight = 1f }
}
}
};
}
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>
{
["NotAllowed"] = "You do not have permission to use this command.",
["Reloaded"] = "Configuration reloaded: {0} loot table(s) active.",
["Usage"] = "Usage: /npcloot reload | /npcloot test <prefab>",
["NoTable"] = "No loot table found for prefab '{0}'.",
["TableHeader"] = "Loot table for '{0}': items {1}-{2}, {3} entries.",
["TableEntry"] = "{0} x{1}-{2} - {3}%",
["TestEmpty"] = "That loot table has no entries."
}, 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(PermAdmin, this);
}
private void Loaded()
{
BuildTables();
}
private void OnServerInitialized()
{
BuildTables();
}
private void BuildTables()
{
tables.Clear();
if (config?.LootTables == null) return;
foreach (var kvp in config.LootTables)
{
if (string.IsNullOrEmpty(kvp.Key) || kvp.Value == null) continue;
string key = kvp.Key.ToLowerInvariant();
if (tables.ContainsKey(key)) continue;
tables[key] = kvp.Value;
}
foreach (var table in tables.Values)
{
if (table.Entries == null) continue;
foreach (var entry in table.Entries)
{
if (entry == null || string.IsNullOrEmpty(entry.Shortname)) continue;
if (ItemManager.FindItemDefinition(entry.Shortname) == null)
{
PrintWarning($"Unknown item shortname in loot table: {entry.Shortname}");
}
}
}
Puts($"Loaded {tables.Count} NPC loot table(s).");
}
private object OnCorpsePopulate(NPCPlayer nPCPlayer, NPCPlayerCorpse nPCPlayerCorpse)
{
if (nPCPlayer == null || nPCPlayerCorpse == null) return null;
if (string.IsNullOrEmpty(nPCPlayer.ShortPrefabName)) return null;
string prefab = nPCPlayer.ShortPrefabName.ToLowerInvariant();
if (!tables.TryGetValue(prefab, out var table) || table == null) return null;
var containers = new List<ItemContainer>();
if (nPCPlayer.inventory != null)
{
if (nPCPlayer.inventory.containerMain != null) containers.Add(nPCPlayer.inventory.containerMain);
if (nPCPlayer.inventory.containerWear != null) containers.Add(nPCPlayer.inventory.containerWear);
if (nPCPlayer.inventory.containerBelt != null) containers.Add(nPCPlayer.inventory.containerBelt);
}
if (containers.Count == 0) return null;
foreach (var container in containers)
{
if (container?.itemList == null || container.itemList.Count == 0) continue;
var items = new List<Item>(container.itemList);
for (int i = 0; i < items.Count; i++)
{
items[i]?.Remove();
}
container.MarkDirty();
}
if (table.Entries == null || table.Entries.Count == 0) return null;
int min = Mathf.Max(1, table.MinItems);
int max = Mathf.Max(min, table.MaxItems);
int count = UnityEngine.Random.Range(min, max + 1);
var target = containers[0];
Vector3 dropPos = nPCPlayerCorpse.transform != null
? nPCPlayerCorpse.transform.position
: nPCPlayer.transform.position;
for (int i = 0; i < count; i++)
{
var entry = RollEntry(table.Entries);
if (entry == null) continue;
var definition = ItemManager.FindItemDefinition(entry.Shortname);
if (definition == null) continue;
int amount = UnityEngine.Random.Range(
Mathf.Max(1, entry.MinAmount),
Mathf.Max(Mathf.Max(1, entry.MinAmount), entry.MaxAmount) + 1);
amount = Mathf.Min(amount, Mathf.Max(1, definition.stackable));
var item = ItemManager.CreateByName(entry.Shortname, amount);
if (item == null) continue;
if (target != null && item.MoveToContainer(target))
{
continue;
}
ItemContainer fallback = null;
for (int c = 1; c < containers.Count; c++)
{
if (containers[c] != null && !containers[c].IsFull())
{
fallback = containers[c];
break;
}
}
if (fallback != null && item.MoveToContainer(fallback))
{
continue;
}
item.Drop(dropPos, Vector3.zero);
}
foreach (var container in containers)
{
container?.MarkDirty();
}
return nPCPlayerCorpse;
}
private LootEntry RollEntry(List<LootEntry> entries)
{
float total = 0f;
for (int i = 0; i < entries.Count; i++)
{
var e = entries[i];
if (e == null || string.IsNullOrEmpty(e.Shortname) || e.Weight <= 0f) continue;
total += e.Weight;
}
if (total <= 0f) return null;
float roll = UnityEngine.Random.Range(0f, total);
for (int i = 0; i < entries.Count; i++)
{
var e = entries[i];
if (e == null || string.IsNullOrEmpty(e.Shortname) || e.Weight <= 0f) continue;
roll -= e.Weight;
if (roll <= 0f) return e;
}
return null;
}
[ChatCommand("npcloot")]
private void CmdNpcLoot(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermAdmin))
{
player.ChatMessage(Msg("NotAllowed", player.UserIDString));
return;
}
if (args == null || args.Length == 0)
{
player.ChatMessage(Msg("Usage", player.UserIDString));
return;
}
switch (args[0].ToLowerInvariant())
{
case "reload":
LoadConfig();
BuildTables();
player.ChatMessage(Msg("Reloaded", player.UserIDString, tables.Count));
break;
case "test":
if (args.Length < 2)
{
player.ChatMessage(Msg("Usage", player.UserIDString));
return;
}
string prefab = args[1].ToLowerInvariant();
if (!tables.TryGetValue(prefab, out var table) || table == null)
{
player.ChatMessage(Msg("NoTable", player.UserIDString, prefab));
return;
}
if (table.Entries == null || table.Entries.Count == 0)
{
player.ChatMessage(Msg("TestEmpty", player.UserIDString));
return;
}
float total = 0f;
foreach (var entry in table.Entries)
{
if (entry != null && entry.Weight > 0f) total += entry.Weight;
}
player.ChatMessage(Msg("TableHeader", player.UserIDString, prefab, table.MinItems, table.MaxItems, table.Entries.Count));
foreach (var entry in table.Entries)
{
if (entry == null) continue;
float percent = total > 0f ? entry.Weight / total * 100f : 0f;
player.ChatMessage(Msg("TableEntry", player.UserIDString, entry.Shortname, entry.MinAmount, entry.MaxAmount, percent.ToString("0.0")));
}
break;
default:
player.ChatMessage(Msg("Usage", player.UserIDString));
break;
}
}
private void Unload()
{
tables.Clear();
}
}
}Hooks used: OnServerInitialized, OnCorpsePopulate
Config file
Created automatically on first load at oxide/config/NpcCorpseLoot.json.
{
"Loot tables (short prefab name -> table)": {
"scientistnpc_roam": {
"MinItems": 3,
"MaxItems": 5,
"Entries": [
{
"Shortname": "scrap",
"MinAmount": 20,
"MaxAmount": 60,
"Weight": 30
},
{
"Shortname": "syringe.medical",
"MinAmount": 1,
"MaxAmount": 1,
"Weight": 20
},
{
"Shortname": "ammo.rifle",
"MinAmount": 10,
"MaxAmount": 15,
"Weight": 25
},
{
"Shortname": "rifle.ak",
"MinAmount": 1,
"MaxAmount": 1,
"Weight": 1
}
]
},
"scientistnpc_heavy": {
"MinItems": 4,
"MaxItems": 6,
"Entries": [
{
"Shortname": "scrap",
"MinAmount": 50,
"MaxAmount": 120,
"Weight": 20
},
{
"Shortname": "ammo.rifle.hv",
"MinAmount": 10,
"MaxAmount": 20,
"Weight": 20
},
{
"Shortname": "weapon.mod.holosight",
"MinAmount": 1,
"MaxAmount": 1,
"Weight": 5
},
{
"Shortname": "rifle.l96",
"MinAmount": 1,
"MaxAmount": 1,
"Weight": 1
}
]
}
}
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/npcloot reload | Reload the plugin configuration from oxide/config and rebuild the active tables. | npccorpseloot.admin |
/npcloot test <prefab> | Print the loot table for a short prefab name to chat, with each entry's chance percentage. | npccorpseloot.admin |
| Permission | Grants |
|---|---|
npccorpseloot.admin | Grants the /npcloot commands: config reload and table inspection. |
How the code works
Detect the corpse and match the prefab
object OnCorpsePopulate(NPCPlayer nPCPlayer, NPCPlayerCorpse nPCPlayerCorpse)runs as the corpse entity is set up. Both parameters andnPCPlayer.ShortPrefabNameare null-checked first, then the plugin lowercases the prefab name and looks it up in the privatetablesdictionary. If no table matches, the method returnsnull, which lets Oxide hand control back to Rust so the default loot stays. Only prefabs you explicitly list are affected.Empty the inventory containers
The plugin builds a list of the NPC's
inventory.containerMain,containerWearandcontainerBeltwhen they are non-null. For each container with a non-null, non-emptyitemList, it copies the items into a newList<Item>, callsRemove()on each copy, thenMarkDirty(). Copying first avoids modifying the live list while iterating it. The method bails out early withnullif that list is empty, and again if the matched table has no entries.Roll the weighted table
RollEntrysumsWeightacross entries that have a shortname and a weight above zero, picks a random offset withUnityEngine.Random.Range(0f, total), then walks the list subtracting weights until the running value hits zero or below. Higher weight means more frequent, not more items.MinItemsandMaxItemsset how many rolls happen; the count comes fromUnityEngine.Random.Range(min, max + 1), withminforced to at least 1.Create and place items
Amounts are rolled between
MinAmountandMaxAmount, then clamped to the definition'sstackablefromItemManager.FindItemDefinition. Items are created withItemManager.CreateByName(shortname, amount); a null return means the shortname is unknown and the roll is skipped. The plugin triesMoveToContaineron the first container, then on each later non-full container in the list, and finally drops the item at the corpse position (or the NPC position if the corpse transform is missing) withDrop(dropPos, Vector3.zero).Config layout
PluginConfig.LootTablesis a dictionary of prefab name toLootTable. Each table holdsMinItems,MaxItemsand anEntrieslist whoseLootEntryobjects haveShortname,MinAmount,MaxAmountandWeight. The defaults seed two tables,scientistnpc_roamandscientistnpc_heavy, so an admin can copy the shape. When the plugin loads,BuildTableslowercases every key intotablesand lets the hook do case-insensitive matching.Commands and startup validation
/npcloot reloadcallsLoadConfig()andBuildTables()and replies with the active table count;/npcloot test <prefab>prints the header and each entry with its percentage, usingMsgto format lang strings. Both checknpccorpseloot.adminthroughpermission.UserHasPermissionand reply withNotAllowedotherwise.BuildTableswalks every entry and callsPrintWarningfor shortnames thatItemManager.FindItemDefinitioncannot resolve, so mistakes appear each time the tables are rebuilt.Cleanup
The plugin keeps no timers, no UI and no saved data.
Unloadsimply callstables.Clear()so the dictionary is emptied; a later config load orOnServerInitializedrepopulates it. That is the entire teardown. The plugin has no per-player state and no entity references to release, so there is nothing else to do here.Putswrites the table count to the console once perBuildTablescall, which is a startup-time informational line only.
Install it on your server
Save NpcCorpseLoot.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/NpcCorpseLoot.json(Carbon:carbon/configs/NpcCorpseLoot.json). After editing, reload it:oxide.reload NpcCorpseLoot c.reload NpcCorpseLoot # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default npccorpseloot.admin c.grant group default npccorpseloot.admin # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Add a guaranteed rare drop
Adds an always-dropped item on top of the weighted rolls for heavyweight NPCs.
Generate this versionAdd a per-player cooldown
Limits how often one player can farm custom loot from the same NPC type.
Generate this versionScale loot by NPC tier
Multiplies min and max amounts based on a tier value in the config.
Generate this version
FAQ
How do I install this on Oxide or Carbon?
Drop the .cs file into oxide/plugins and load it with oxide.reload NpcCorpseLoot in the server console. Carbon reads the same folder, so the same file works there. Edit oxide/config/NpcCorpseLoot.json to add prefab tables, then use /npcloot reload so changes apply without a restart.
Which prefab names should I use as table keys?
Use the short prefab name of the NPC, lowercased, for example scientistnpc_roam. You can find it by killing the NPC and reading the corpse's prefab name, or by checking the NPC prefab in your spawn plugin. Anything not listed in LootTables keeps Rust's default loot.
Does this affect player corpses or animals?
No. The hook's first parameter is typed NPCPlayer, and the plugin returns null for any prefab that is not a key in tables. Player corpses and animal corpses therefore keep whatever loot Rust would normally generate for them.
Why did my custom loot not appear?
Check the server console when the plugin loads or after /npcloot reload: BuildTables calls PrintWarning for unknown shortnames. Also confirm MinItems is at least 1, that each entry has a weight above zero, and that the prefab key matches exactly after lowercasing. /npcloot test <prefab> prints what the plugin sees.
What does returning null from OnCorpsePopulate do?
It tells Oxide your plugin did not handle the corpse, so Rust continues with its own loot generation. The plugin returns null for unmatched prefabs and for empty tables, which is exactly why mixed NPC sets on a modded server behave normally.
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 npcs plugins
- Animal spawn commandAdds a permission-gated /animal <species> [count] chat command that spawns vanilla animals in front of the player, with a per-command cap, aโฆ
- NPC free zonesDefines named world zones where newly spawned scientists and animals are deleted immediately.OnEntitySpawned
- NPC damage multiplierScales damage NPCs deal to players and damage players deal to NPCs independently, with per-NPC-type overrides, using Oxide'sโฆOnEntityTakeDamage
Last updated Oct 26, 2026.
Customize custom npc loot
Customize