Skip to content
RustPluginGenerator

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

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

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

Chat and console commands
CommandWhat it doesPermission
/flbShows the current bonus chance, enabled state and tracked container count.firstlootbonus.admin
/flb reloadReloads the config file and clears the per-player cooldowns.firstlootbonus.admin
flb.statusPrints the enabled state, chance and tracked container count to the console.firstlootbonus.admin
Permissions
PermissionGrants
firstlootbonus.adminGrants access to /flb, /flb reload and the flb.status console command.

How the code works

  1. 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 null player or targetEntity, an NPC operator (player.IsNpc) or a disconnected player (!player.IsConnected). Nothing here calls Puts or PrintWarning, because the hook fires on every container open on the server.

  2. 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. When config.OnlyCrates is true, only StorageContainer instances pass the is StorageContainer check. The container's inventory is read from containerEntity.Inventory; the hook returns if it is null or IsFull().

  3. Claim each container once

    The plugin keeps a HashSet<ulong> claimed keyed by targetEntity.net.ID.Value. If the id is already present the hook returns. The id is added with claimed.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.

  4. Roll the chance and the cooldown

    The per-open chance is config.BonusChance (0-100 percent), rolled with UnityEngine.Random.Range(0f, 100f); anything at or above the value returns without touching the container. A per-player cooldown in config.CooldownSeconds is stored in the cooldowns dictionary keyed by player.userID, compared against UnityEngine.Time.realtimeSinceStartup, so one player cannot roll many containers back to back.

  5. Pick the item and place it

    Items live in config.BonusItems, each with Shortname, Amount and Weight. PickBonus sums the weights, rolls UnityEngine.Random.Range(0f, total) and subtracts weights until the roll drops to zero or below. The chosen entry is created with ItemManager.CreateByName and moved with item.MoveToContainer(container). If the move fails the item is dropped at the container with item.Drop, otherwise container.MarkDirty() is called.

  6. Tell the player

    When an item is placed and config.NotifyPlayer is true, the plugin sends the BonusReceived lang key via SendReply, filled with the item's displayName.english and amount. SendReply prints above the loot UI without closing it. The CooldownSeconds timestamp is written only after a successful roll, so a miss does not start the cooldown.

  7. Admin controls and cleanup

    /flb reports the enabled flag, chance and claimed.Count using the Status lang key; /flb reload runs LoadConfig and clears cooldowns. The flb.status console command replies with the same figures, checking permission.UserHasPermission when run by a player. Unload clears claimed and cooldowns so a reload starts clean; no UI is involved.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip: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 version
  • Bonus depends on container tier

    Gives a different item list and chance per crate prefab.

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

Prompt: Create a Rust Oxide plugin that rolls a 15 percent chance to add one bonus item from a weighted list the first time each loot container is opened, using OnLootEntity, with a per-player cooldown of 60 seconds and a /flb admin command.
Customize for my server

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

Last updated Oct 26, 2026.

Customize first loot bonus

Customize