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
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.
{
"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
| Command | What it does | Permission |
|---|---|---|
/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 |
/bounties | Lists 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 |
| Permission | Grants |
|---|---|
bountysystem.use | Allows placing bounties and listing the bounty board. |
How the code works
Data model and persistence
The data file is a
Dictionary<string, BountyEntry>keyed by the target'sUserIDString. EachBountyEntryhas aNamestring and aBackerslist; eachBackerstores aUserId, anAmount, and aName. The pool total is computed byTotal(), which sums the backer amounts. The dictionary is loaded inOnServerInitializedwithInterface.Oxide.DataFileSystem.ReadObjectand written bySaveBountiesafter every mutation and inUnload. The data file is separate from the config file, which only holds the three tuning options.Validating the /bounty command
CmdBountychecksbountysystem.use, then resolves the target withBasePlayer.FindAwakeOrSleeping(args[0])and parsesargs[1]into an integer. It rejects a missing target, a target equal to yourself byuserID, a target on your team whenBlockSameTeamis true and bothcurrentTeamvalues are non-zero, an amount belowMinimumBounty, and a scrap total below the requested amount. The scrap is removed withplayer.inventory.Take(null, ScrapId, amount), whereScrapIdresolves the item id fromItemManager.FindItemDefinition("scrap")with a hard-coded fallback.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 sameUserIdif one exists, otherwise it adds a newBacker.SaveBountiesruns immediately. WhenBroadcastNewBountiesis true the plugin callsServer.Broadcastwith theBroadcastlang message; the placer always gets thePlacedmessage with the amount taken, the target name, and the new pool total. Because the stored amount is the value returned byTake, the recorded total matches what actually left the inventory.Paying out in OnEntityDeath
OnEntityDeath(BaseCombatEntity entity, HitInfo info)returns early unless the dead entity casts toBasePlayerandinfo.InitiatorPlayeris set. It then finds the victim's entry in the dictionary and returns if there is none. IfBlockSameTeamis true and the killer and victim share a non-zerocurrentTeam, the claim is skipped and the entry stays in place. On success it creates the scrap withItemManager.CreateByItemID(ScrapId, total), callskiller.GiveItem, removes the entry, saves, and sends theClaimedmessage to the killer andVictimto the victim when they are still connected.Handling drops when the killer's inventory is full
BasePlayer.GiveItemdrops 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 withCreateByItemID, 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 callingGiveItemand give each chunk separately.Refunds and clearing
The console command
bountysystem.cancelis admin-gated when run by a player and takes a Steam ID argument. It iterates the backer list and gives each backer their scrap throughBasePlayer.FindByID, skipping anyone who is offline or disconnected with aPrintWarning. 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
Save BountySystem.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/BountySystem.json(Carbon:carbon/configs/BountySystem.json). After editing, reload it:oxide.reload BountySystem c.reload BountySystem # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip: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 versionBounty expiry
Refund backers automatically after a configurable number of hours.
Generate this versionMinimum 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.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More economy plugins
- Chat shopA chat-driven shop where players spend scrap from their inventory to buy configured items.
- Daily rewardsAdds a /daily command that lets each player claim one reward from a configurable weighted list every 24 hours, with a streak bonus that scales whileโฆOnServerInitializedOnServerSave
- Death penaltyMoves a configurable percentage of a dead player's carried scrap to their killer using OnPlayerDeath, with min/max clamps and separate handling forโฆOnServerInitializedOnPlayerDeath
Last updated Oct 26, 2026.
Customize bounties
Customize