Skip to content
RustPluginGenerator

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

NpcCorpseLoot.cs
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.

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

Chat and console commands
CommandWhat it doesPermission
/npcloot reloadReload 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
Permissions
PermissionGrants
npccorpseloot.adminGrants the /npcloot commands: config reload and table inspection.

How the code works

  1. Detect the corpse and match the prefab

    object OnCorpsePopulate(NPCPlayer nPCPlayer, NPCPlayerCorpse nPCPlayerCorpse) runs as the corpse entity is set up. Both parameters and nPCPlayer.ShortPrefabName are null-checked first, then the plugin lowercases the prefab name and looks it up in the private tables dictionary. If no table matches, the method returns null, which lets Oxide hand control back to Rust so the default loot stays. Only prefabs you explicitly list are affected.

  2. Empty the inventory containers

    The plugin builds a list of the NPC's inventory.containerMain, containerWear and containerBelt when they are non-null. For each container with a non-null, non-empty itemList, it copies the items into a new List<Item>, calls Remove() on each copy, then MarkDirty(). Copying first avoids modifying the live list while iterating it. The method bails out early with null if that list is empty, and again if the matched table has no entries.

  3. Roll the weighted table

    RollEntry sums Weight across entries that have a shortname and a weight above zero, picks a random offset with UnityEngine.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. MinItems and MaxItems set how many rolls happen; the count comes from UnityEngine.Random.Range(min, max + 1), with min forced to at least 1.

  4. Create and place items

    Amounts are rolled between MinAmount and MaxAmount, then clamped to the definition's stackable from ItemManager.FindItemDefinition. Items are created with ItemManager.CreateByName(shortname, amount); a null return means the shortname is unknown and the roll is skipped. The plugin tries MoveToContainer on 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) with Drop(dropPos, Vector3.zero).

  5. Config layout

    PluginConfig.LootTables is a dictionary of prefab name to LootTable. Each table holds MinItems, MaxItems and an Entries list whose LootEntry objects have Shortname, MinAmount, MaxAmount and Weight. The defaults seed two tables, scientistnpc_roam and scientistnpc_heavy, so an admin can copy the shape. When the plugin loads, BuildTables lowercases every key into tables and lets the hook do case-insensitive matching.

  6. Commands and startup validation

    /npcloot reload calls LoadConfig() and BuildTables() and replies with the active table count; /npcloot test <prefab> prints the header and each entry with its percentage, using Msg to format lang strings. Both check npccorpseloot.admin through permission.UserHasPermission and reply with NotAllowed otherwise. BuildTables walks every entry and calls PrintWarning for shortnames that ItemManager.FindItemDefinition cannot resolve, so mistakes appear each time the tables are rebuilt.

  7. Cleanup

    The plugin keeps no timers, no UI and no saved data. Unload simply calls tables.Clear() so the dictionary is emptied; a later config load or OnServerInitialized repopulates 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. Puts writes the table count to the console once per BuildTables call, which is a startup-time informational line only.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip: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 version
  • Add a per-player cooldown

    Limits how often one player can farm custom loot from the same NPC type.

    Generate this version
  • Scale 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.

Prompt: Create a Rust Oxide plugin that replaces corpse loot for configured NPC prefabs using OnCorpsePopulate, with a weighted table per short prefab name and an npccorpseloot.admin permission.
Customize for my server

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

Last updated Oct 26, 2026.

Customize custom npc loot

Customize