How to make a Rust first loot bonus plugin (chance-based crate bonus)
This plugin hooks OnLootEntity and rolls a configurable chance to insert one bonus item from a weighted list into a loot container the first time it is opened. Each container is tracked by net ID and pays out at most one bonus.
Servers that want loot to feel less flat usually want a random extra reward, but placing the extra item when the crate spawns lets a player see it in advance. A first-open bonus is different: the roll happens when a player actually opens the container, so it works for every lootable entity, including ones spawned by other plugins.
This plugin listens to the OnLootEntity hook, which Oxide calls with the BasePlayer and the BaseEntity being looted. It casts the target to IItemContainerEntity, rolls the configured chance, picks a weighted item from the list and moves it into the container's inventory. A HashSet<ulong> of container net IDs makes sure no container ever pays out twice, and a short per-player cooldown stops anyone from farming containers for the roll.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("First Loot Bonus", "RustPluginGenerator", "1.0.0")]
[Description("Rolls a configured chance to add one bonus item the first time each loot container is opened.")]
public class FirstLootBonus : RustPlugin
{
private const string PermAdmin = "firstlootbonus.admin";
private PluginConfig config;
private readonly HashSet<ulong> claimed = new HashSet<ulong>();
private readonly Dictionary<ulong, float> cooldowns = new Dictionary<ulong, float>();
private class PluginConfig
{
[JsonProperty("Enabled")]
public bool Enabled = true;
[JsonProperty("Bonus chance (percent)")]
public float BonusChance = 15f;
[JsonProperty("Only plain crates")]
public bool OnlyCrates = false;
[JsonProperty("Per-player cooldown (seconds)")]
public float CooldownSeconds = 60f;
[JsonProperty("Notify player in chat")]
public bool NotifyPlayer = true;
[JsonProperty("Bonus items")]
public List<BonusItem> BonusItems = new List<BonusItem>
{
new BonusItem { Shortname = "scrap", Amount = 25, Weight = 50f },
new BonusItem { Shortname = "metal.fragments", Amount = 200, Weight = 30f },
new BonusItem { Shortname = "rifle.ak", Amount = 1, Weight = 15f },
new BonusItem { Shortname = "weapon.mod.lasersight", Amount = 1, Weight = 5f }
};
}
private class BonusItem
{
[JsonProperty("Shortname")]
public string Shortname;
[JsonProperty("Amount")]
public int Amount;
[JsonProperty("Weight")]
public float Weight;
}
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>
{
["BonusReceived"] = "You found a bonus: {0} x{1}",
["Status"] = "First loot bonus enabled: {0}. Chance: {1}%. Tracked containers: {2}",
["Reloaded"] = "First loot bonus config reloaded.",
["NoPerm"] = "You do not have permission to use this command."
}, 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);
}
[ChatCommand("flb")]
private void CmdFlb(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermAdmin))
{
SendReply(player, Msg("NoPerm", player.UserIDString));
return;
}
if (args != null && args.Length > 0 && args[0].ToLowerInvariant() == "reload")
{
LoadConfig();
cooldowns.Clear();
SendReply(player, Msg("Reloaded", player.UserIDString));
return;
}
SendReply(player, Msg("Status", player.UserIDString, config.Enabled, config.BonusChance, claimed.Count));
}
[ConsoleCommand("flb.status")]
private void CcmdStatus(ConsoleSystem.Arg arg)
{
if (arg == null) return;
var player = arg.Player();
if (player != null && !permission.UserHasPermission(player.UserIDString, PermAdmin))
{
arg.ReplyWith("No permission.");
return;
}
arg.ReplyWith($"Enabled: {config.Enabled}, chance: {config.BonusChance}%, tracked: {claimed.Count}");
}
private void OnLootEntity(BasePlayer player, BaseEntity targetEntity)
{
if (!config.Enabled || player == null || targetEntity == null) return;
if (player.IsNpc || !player.IsConnected) return;
if (config.OnlyCrates && !(targetEntity is StorageContainer)) return;
var containerEntity = targetEntity as IItemContainerEntity;
if (containerEntity == null) return;
ItemContainer container = containerEntity.inventory;
if (container == null || container.IsFull()) return;
ulong id = targetEntity.net.ID.Value;
if (claimed.Contains(id)) return;
float now = UnityEngine.Time.realtimeSinceStartup;
if (config.CooldownSeconds > 0f)
{
float last;
if (cooldowns.TryGetValue(player.userID, out last) && now - last < config.CooldownSeconds) return;
}
claimed.Add(id);
if (UnityEngine.Random.Range(0f, 100f) >= config.BonusChance) return;
BonusItem pick = PickBonus();
if (pick == null) return;
Item item = ItemManager.CreateByName(pick.Shortname, Mathf.Max(1, pick.Amount));
if (item == null) return;
if (!item.MoveToContainer(container))
{
item.Drop(targetEntity.transform.position + Vector3.up * 0.5f, Vector3.zero);
}
else
{
container.MarkDirty();
}
if (config.CooldownSeconds > 0f)
{
cooldowns[player.userID] = now;
}
if (config.NotifyPlayer)
{
SendReply(player, Msg("BonusReceived", player.UserIDString, item.info.displayName.english, item.amount));
}
}
private BonusItem PickBonus()
{
if (config.BonusItems == null || config.BonusItems.Count == 0) return null;
float total = 0f;
foreach (var entry in config.BonusItems)
{
if (entry == null || string.IsNullOrEmpty(entry.Shortname) || entry.Weight <= 0f) continue;
total += entry.Weight;
}
if (total <= 0f) return null;
float roll = UnityEngine.Random.Range(0f, total);
foreach (var entry in config.BonusItems)
{
if (entry == null || string.IsNullOrEmpty(entry.Shortname) || entry.Weight <= 0f) continue;
roll -= entry.Weight;
if (roll <= 0f) return entry;
}
return null;
}
private void Unload()
{
claimed.Clear();
cooldowns.Clear();
}
}
}Hooks used: OnLootEntity
Config file
Created automatically on first load at oxide/config/FirstLootBonus.json.
{
"Enabled": true,
"Bonus chance (percent)": 15,
"Only plain crates": false,
"Per-player cooldown (seconds)": 60,
"Notify player in chat": true,
"Bonus items": [
{
"Shortname": "scrap",
"Amount": 25,
"Weight": 50
},
{
"Shortname": "metal.fragments",
"Amount": 200,
"Weight": 30
},
{
"Shortname": "rifle.ak",
"Amount": 1,
"Weight": 15
},
{
"Shortname": "weapon.mod.lasersight",
"Amount": 1,
"Weight": 5
}
]
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/flb | Shows the current bonus chance, enabled state and tracked container count. | firstlootbonus.admin |
/flb reload | Reloads the config file and clears the per-player cooldowns. | firstlootbonus.admin |
flb.status | Prints the enabled state, chance and tracked container count to the console. | firstlootbonus.admin |
| Permission | Grants |
|---|---|
firstlootbonus.admin | Grants access to /flb, /flb reload and the flb.status console command. |
How the code works
Catch the container when it opens
private void OnLootEntity(BasePlayer player, BaseEntity targetEntity)fires when a player starts looting an entity. The hook returns void, so it cannot cancel the loot. The first lines bail out on!config.Enabled, a nullplayerortargetEntity, an NPC operator (player.IsNpc) or a disconnected player (!player.IsConnected). Nothing here callsPutsorPrintWarning, because the hook fires on every container open on the server.Decide what counts as a crate
The code casts the target with
targetEntity as IItemContainerEntity. Anything that is not an item container returns null and is ignored. Whenconfig.OnlyCratesis true, onlyStorageContainerinstances pass theis StorageContainercheck. The container's inventory is read fromcontainerEntity.Inventory; the hook returns if it is null orIsFull().Claim each container once
The plugin keeps a
HashSet<ulong> claimedkeyed bytargetEntity.net.ID.Value. If the id is already present the hook returns. The id is added withclaimed.Add(id)before the chance roll, so a miss still marks the container as used and it can never be retried for a second bonus.Roll the chance and the cooldown
The per-open chance is
config.BonusChance(0-100 percent), rolled withUnityEngine.Random.Range(0f, 100f); anything at or above the value returns without touching the container. A per-player cooldown inconfig.CooldownSecondsis stored in thecooldownsdictionary keyed byplayer.userID, compared againstUnityEngine.Time.realtimeSinceStartup, so one player cannot roll many containers back to back.Pick the item and place it
Items live in
config.BonusItems, each withShortname,AmountandWeight.PickBonussums the weights, rollsUnityEngine.Random.Range(0f, total)and subtracts weights until the roll drops to zero or below. The chosen entry is created withItemManager.CreateByNameand moved withitem.MoveToContainer(container). If the move fails the item is dropped at the container withitem.Drop, otherwisecontainer.MarkDirty()is called.Tell the player
When an item is placed and
config.NotifyPlayeris true, the plugin sends theBonusReceivedlang key viaSendReply, filled with the item'sdisplayName.englishand amount.SendReplyprints above the loot UI without closing it. TheCooldownSecondstimestamp is written only after a successful roll, so a miss does not start the cooldown.Admin controls and cleanup
/flbreports the enabled flag, chance andclaimed.Countusing theStatuslang key;/flb reloadrunsLoadConfigand clearscooldowns. Theflb.statusconsole command replies with the same figures, checkingpermission.UserHasPermissionwhen run by a player.Unloadclearsclaimedandcooldownsso a reload starts clean; no UI is involved.
Install it on your server
Save FirstLootBonus.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/FirstLootBonus.json(Carbon:carbon/configs/FirstLootBonus.json). After editing, reload it:oxide.reload FirstLootBonus c.reload FirstLootBonus # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default firstlootbonus.admin c.grant group default firstlootbonus.admin # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Helicopter and Bradley crates only
Restricts the bonus to HackableLockedCrate prefabs and Bradley APC crates.
Generate this versionBonus depends on container tier
Gives a different item list and chance per crate prefab.
Generate this versionPlayer cooldown with message
Locks out a player from bonus rolls for a while and tells them why.
Generate this version
FAQ
Does the bonus apply to containers spawned later, or only at world start?
The roll happens in OnLootEntity, so it applies to every lootable container at the moment a player opens it, no matter when or by whom the container was created. Crates generated by other plugins are covered too.
What happens if the container is full?
MoveToContainer returns false and the plugin drops the item at the container position with item.Drop so the reward is not lost. If you would rather skip the bonus in that case, remove the Drop call from the code.
Are bonuses remembered across restarts?
No. The tracked container IDs live in memory only, so after a restart each container is treated as unopened again. Persisting them is possible, but the set grows quickly and most admins want fresh bonuses after a wipe.
How do I install this?
Place FirstLootBonus.cs in oxide/plugins on Oxide or Carbon and let the server compile it. Edit oxide/config/FirstLootBonus.json to set BonusChance and the item list, then run oxide.reload FirstLootBonus.
Why did the crate not give me a bonus after several opens?
The roll is per container and per open. A low BonusChance means most opens miss, and each container ID is only tracked once, so reopening the same crate never pays out again. Check flb.status to confirm the chance and the tracked count.
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 loot plugins
- Barrel scrap bonusAwards bonus scrap when a player destroys a loot barrel, with a configurable chance and a VIP multiplier permission.OnEntityDeath
- First opener crate lockThis plugin gives the first player who opens a loot container a temporary claim on it, enforced through the CanLootEntity hook, so anyone outsideโฆCanLootEntityOnEntityKill
- Custom loot tablesReplaces the contents of configured LootContainer crates, matched by short prefab name, with weighted items that have per-item chances and min/maxโฆOnLootSpawn
Last updated Oct 26, 2026.
Customize first loot bonus
Customize