Skip to content
RustPluginGenerator

How to make a Rust bounty plugin (scrap bounty pool)

A scrap bounty system where players fund a pool on a target and the killer collects it, using the OnEntityDeath hook plus a data file for persistence and team checks to block friendly claims.

Bounty boards give players an economic reason to hunt each other beyond looting. The problem with a naive implementation is edge cases: the placer killing their own target, teammates farming each other, and bounties surviving server restarts.

This plugin places a scrap bounty with /bounty <player> <amount>, removes the scrap from the placer's inventory, and adds it to a pool keyed by the target's Steam ID. Once the pool exists, /bounties lists every active target and the pool total. Payout happens in OnEntityDeath: the plugin reads the killer from the kill HitInfo, checks that the killer is not the target and not on the target's team, then gives the whole pool and clears the entry. Bounties are written to oxide/data/BountySystem.json on every change, so a restart does not wipe the board. Team and self-claim checks are the only thing standing between this and an exploit, so they run before any scrap is awarded.

The plugin code

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

namespace Oxide.Plugins
{
    [Info("BountySystem", "RustPluginGenerator", "1.0.0")]
    [Description("Scrap bounties on players that pay out to the killer on death.")]
    public class BountySystem : RustPlugin
    {
        private const string PermUse = "bountysystem.use";
        private const string DataFile = "BountySystem";

        private PluginConfig config;
        private Dictionary<string, BountyEntry> bounties = new Dictionary<string, BountyEntry>();

        private class BountyEntry
        {
            [JsonProperty("Target name")]
            public string Name;

            [JsonProperty("Backers")]
            public List<Backer> Backers = new List<Backer>();

            public int Total()
            {
                int total = 0;
                foreach (var b in Backers) total += b.Amount;
                return total;
            }
        }

        private class Backer
        {
            [JsonProperty("Steam ID")]
            public string UserId;

            [JsonProperty("Amount")]
            public int Amount;

            [JsonProperty("Name")]
            public string Name;
        }

        private class PluginConfig
        {
            [JsonProperty("Minimum bounty amount")]
            public int MinimumBounty = 10;

            [JsonProperty("Block killers from the target's team")]
            public bool BlockSameTeam = true;

            [JsonProperty("Broadcast new bounties")]
            public bool BroadcastNewBounties = true;
        }

        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>
            {
                ["Placed"] = "<color=#ffb347>[Bounty]</color> You placed {0} scrap on {1}. Current pool: {2}.",
                ["Broadcast"] = "<color=#ffb347>[Bounty]</color> {0} now has a bounty of {1} scrap.",
                ["Claimed"] = "<color=#ffb347>[Bounty]</color> You claimed {0} scrap from killing {1}.",
                ["Victim"] = "<color=#ffb347>[Bounty]</color> {0} collected the {1} scrap bounty on you.",
                ["ListHeader"] = "<color=#ffb347>[Bounty]</color> Active bounties ({0}):",
                ["ListItem"] = "{0} - pool {1} scrap",
                ["ListEmpty"] = "<color=#ffb347>[Bounty]</color> There are no active bounties.",
                ["NoPermission"] = "You do not have permission to use bounties.",
                ["InvalidArgs"] = "Usage: /bounty <player> <amount>",
                ["NotFound"] = "No player found with that name or ID.",
                ["Self"] = "You cannot place a bounty on yourself.",
                ["SameTeam"] = "You cannot place a bounty on a teammate.",
                ["TooLow"] = "The minimum bounty is {0} scrap.",
                ["NoScrap"] = "You do not have {0} scrap.",
                ["Cancelled"] = "Removed all bounties on {0}.",
                ["CancelUsage"] = "Usage: bountysystem.cancel <steamid>"
            }, this);
        }

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

        private int ScrapId => ItemManager.FindItemDefinition("scrap")?.itemid ?? -932201673;

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

        private void OnServerInitialized()
        {
            bounties = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, BountyEntry>>(DataFile) ?? new Dictionary<string, BountyEntry>();
        }

        private void Unload()
        {
            SaveBounties();
        }

        private void SaveBounties()
        {
            Interface.Oxide.DataFileSystem.WriteObject(DataFile, bounties);
        }

        [ChatCommand("bounty")]
        private void CmdBounty(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                SendReply(player, Msg("NoPermission", player.UserIDString));
                return;
            }

            if (args == null || args.Length < 2)
            {
                SendReply(player, Msg("InvalidArgs", player.UserIDString));
                return;
            }

            BasePlayer target = BasePlayer.FindAwakeOrSleeping(args[0]);
            if (target == null)
            {
                SendReply(player, Msg("NotFound", player.UserIDString));
                return;
            }

            if (target.userID == player.userID)
            {
                SendReply(player, Msg("Self", player.UserIDString));
                return;
            }

            if (config.BlockSameTeam && player.currentTeam != 0 && player.currentTeam == target.currentTeam)
            {
                SendReply(player, Msg("SameTeam", player.UserIDString));
                return;
            }

            int amount;
            if (!int.TryParse(args[1], out amount) || amount < config.MinimumBounty)
            {
                SendReply(player, Msg("TooLow", player.UserIDString, config.MinimumBounty));
                return;
            }

            if (player.inventory.GetAmount(ScrapId) < amount)
            {
                SendReply(player, Msg("NoScrap", player.UserIDString, amount));
                return;
            }

            var taken = player.inventory.Take(null, ScrapId, amount);
            if (taken <= 0)
            {
                SendReply(player, Msg("NoScrap", player.UserIDString, amount));
                return;
            }
            taken = Math.Min(taken, amount);

            BountyEntry entry;
            if (!bounties.TryGetValue(target.UserIDString, out entry))
            {
                entry = new BountyEntry { Name = target.displayName };
                bounties[target.UserIDString] = entry;
            }
            entry.Name = target.displayName;

            Backer existing = null;
            foreach (var b in entry.Backers)
            {
                if (b.UserId == player.UserIDString) { existing = b; break; }
            }
            if (existing == null)
            {
                entry.Backers.Add(new Backer { UserId = player.UserIDString, Amount = taken, Name = player.displayName });
            }
            else
            {
                existing.Amount += taken;
            }

            SaveBounties();

            SendReply(player, Msg("Placed", player.UserIDString, taken, target.displayName, entry.Total()));
            if (config.BroadcastNewBounties)
            {
                Server.Broadcast(Msg("Broadcast", null, target.displayName, entry.Total()));
            }
        }

        [ChatCommand("bounties")]
        private void CmdList(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                SendReply(player, Msg("NoPermission", player.UserIDString));
                return;
            }

            if (bounties.Count == 0)
            {
                SendReply(player, Msg("ListEmpty", player.UserIDString));
                return;
            }

            SendReply(player, Msg("ListHeader", player.UserIDString, bounties.Count));
            foreach (var kvp in bounties)
            {
                SendReply(player, Msg("ListItem", player.UserIDString, kvp.Value.Name, kvp.Value.Total()));
            }
        }

        [ConsoleCommand("bountysystem.cancel")]
        private void CcmdCancel(ConsoleSystem.Arg arg)
        {
            var player = arg.Player();
            if (player != null && !player.IsAdmin)
            {
                arg.ReplyWith("Only admins can cancel bounties.");
                return;
            }

            string id = arg.GetString(0, "");
            if (string.IsNullOrEmpty(id))
            {
                arg.ReplyWith(Msg("CancelUsage"));
                return;
            }

            BountyEntry entry;
            if (!bounties.TryGetValue(id, out entry))
            {
                arg.ReplyWith("No bounty on that ID.");
                return;
            }

            foreach (var b in entry.Backers)
            {
                var backer = BasePlayer.FindByID(Convert.ToUInt64(b.UserId));
                if (backer == null || !backer.IsConnected)
                {
                    PrintWarning("Could not refund {0} ({1} scrap) - offline.", b.Name, b.Amount);
                    continue;
                }
                var item = ItemManager.CreateByItemID(ScrapId, b.Amount);
                if (item != null) backer.GiveItem(item);
            }

            bounties.Remove(id);
            SaveBounties();
            arg.ReplyWith(Msg("Cancelled", null, entry.Name));
        }

        private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
        {
            if (entity == null || info == null) return;
            var victim = entity as BasePlayer;
            if (victim == null) return;
            var killer = info.InitiatorPlayer;
            if (killer == null || killer.userID == victim.userID) return;

            BountyEntry entry;
            if (!bounties.TryGetValue(victim.UserIDString, out entry)) return;

            if (config.BlockSameTeam && killer.currentTeam != 0 && killer.currentTeam == victim.currentTeam)
            {
                return;
            }

            int total = entry.Total();
            if (total <= 0)
            {
                bounties.Remove(victim.UserIDString);
                SaveBounties();
                return;
            }

            var item = ItemManager.CreateByItemID(ScrapId, total);
            if (item != null)
            {
                killer.GiveItem(item);
            }

            bounties.Remove(victim.UserIDString);
            SaveBounties();

            SendReply(killer, Msg("Claimed", killer.UserIDString, total, victim.displayName));
            if (victim.IsConnected)
            {
                SendReply(victim, Msg("Victim", victim.UserIDString, killer.displayName, total));
            }
        }
    }
}

Hooks used: OnServerInitialized, OnEntityDeath

Config file

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

BountySystem.json
{
  "Minimum bounty amount": 10,
  "Block killers from the target's team": true,
  "Broadcast new bounties": true,
  "Target name": null,
  "Backers": [],
  "Steam ID": null,
  "Amount": 0,
  "Name": null
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/bounty <player> <amount>Places a scrap bounty on a player, taking the scrap from your inventory and adding it to the target's pool.bountysystem.use
/bountiesLists active bounties with each target and pool total.bountysystem.use
bountysystem.cancel <steamid>Removes all bounties on a player and returns the scrap to each online backer. Admin only.Everyone
Permissions
PermissionGrants
bountysystem.useAllows placing bounties and listing the bounty board.

How the code works

  1. Data model and persistence

    The data file is a Dictionary<string, BountyEntry> keyed by the target's UserIDString. Each BountyEntry has a Name string and a Backers list; each Backer stores a UserId, an Amount, and a Name. The pool total is computed by Total(), which sums the backer amounts. The dictionary is loaded in OnServerInitialized with Interface.Oxide.DataFileSystem.ReadObject and written by SaveBounties after every mutation and in Unload. The data file is separate from the config file, which only holds the three tuning options.

  2. Validating the /bounty command

    CmdBounty checks bountysystem.use, then resolves the target with BasePlayer.FindAwakeOrSleeping(args[0]) and parses args[1] into an integer. It rejects a missing target, a target equal to yourself by userID, a target on your team when BlockSameTeam is true and both currentTeam values are non-zero, an amount below MinimumBounty, and a scrap total below the requested amount. The scrap is removed with player.inventory.Take(null, ScrapId, amount), where ScrapId resolves the item id from ItemManager.FindItemDefinition("scrap") with a hard-coded fallback.

  3. Adding to the pool

    After taking the scrap, the command looks up or creates the BountyEntry, then merges the contribution into the backer record for the same UserId if one exists, otherwise it adds a new Backer. SaveBounties runs immediately. When BroadcastNewBounties is true the plugin calls Server.Broadcast with the Broadcast lang message; the placer always gets the Placed message with the amount taken, the target name, and the new pool total. Because the stored amount is the value returned by Take, the recorded total matches what actually left the inventory.

  4. Paying out in OnEntityDeath

    OnEntityDeath(BaseCombatEntity entity, HitInfo info) returns early unless the dead entity casts to BasePlayer and info.InitiatorPlayer is set. It then finds the victim's entry in the dictionary and returns if there is none. If BlockSameTeam is true and the killer and victim share a non-zero currentTeam, the claim is skipped and the entry stays in place. On success it creates the scrap with ItemManager.CreateByItemID(ScrapId, total), calls killer.GiveItem, removes the entry, saves, and sends the Claimed message to the killer and Victim to the victim when they are still connected.

  5. Handling drops when the killer's inventory is full

    BasePlayer.GiveItem drops items to the ground when they do not fit, so the pool is never lost to a full belt. That behavior is intentional here and keeps the plugin simple. The payout is a single item created with CreateByItemID, so if the pool exceeds one stack the game splits it across the killer's containers or drops the remainder. If you want strict delivery, split the total into chunks before calling GiveItem and give each chunk separately.

  6. Refunds and clearing

    The console command bountysystem.cancel is admin-gated when run by a player and takes a Steam ID argument. It iterates the backer list and gives each backer their scrap through BasePlayer.FindByID, skipping anyone who is offline or disconnected with a PrintWarning. The entry is then removed and the file saved. Use it when a target changes identity or leaves for good. There is no per-backer refund command, so a single backer cannot withdraw independently.

Install it on your server

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

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

Variations

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

  • Economy currency payouts

    Pay the pool through the Economics plugin instead of giving scrap items.

    Generate this version
  • Bounty expiry

    Refund backers automatically after a configurable number of hours.

    Generate this version
  • Minimum pool to broadcast

    Only announce bounties once the pool crosses a threshold.

    Generate this version

FAQ

Can a player claim their own bounty?

No. OnEntityDeath compares the killer and victim userID and returns before any payout. The same hook enforces the team check: with BlockSameTeam enabled, a teammate of the target cannot claim. Disabling it lets organized groups farm their own bounties, which is why it defaults to true.

What happens if the killer's inventory is full?

BasePlayer.GiveItem drops the item at the killer's feet when it cannot be placed in a container, so the scrap is not lost. It can be looted by anyone nearby for a short window. If you want strict delivery, split the payout into stackable chunks and give each one separately.

Does the bounty survive a restart?

Yes. Every mutation writes to oxide/data/BountySystem.json. Loading happens in OnServerInitialized, where a missing or corrupt file yields an empty dictionary. Existing entries keep their backer amounts, so a restart neither resets nor refunds anyone.

Can I require a permission for placing bounties?

bountysystem.use is registered in Init and checked at the top of both chat commands. Grant it to a group with oxide.grant group default bountysystem.use. The console cancel command has no permission registration; instead it rejects non-admin player callers, so it is effectively limited to the server console and RCON.

How do I install it?

Drop BountySystem.cs into oxide/plugins on Oxide or Carbon and reload the plugin. The default config file appears in oxide/config/BountySystem.json; edit it and reload to apply. No other plugin is required.

Customize it for your server

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

Prompt: Build a Rust bounty plugin where /bounty <player> <amount> takes scrap from the placer, /bounties lists pools, killers claim the pool on death, teammates cannot claim, and bounties persist in a data file.
Customize for my server

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

Last updated Oct 26, 2026.

Customize bounties

Customize