How to make a Rust heli and Bradley loot lock plugin (PvE ownership)
Tracks damage dealt to the Patrol Helicopter and Bradley APC per player and locks the crates they drop for a configurable time, allowing only the top damager and their team to loot. Uses OnEntityTakeDamage, OnEntityDeath, OnEntitySpawned and CanLootEntity.
On PvE servers the Patrol Helicopter and the Bradley APC are the two most contested loot events. Two groups fight the same target, someone lands the last shot, and then everyone races to the crates. Whichever group has the fastest looter keeps everything, regardless of who actually did the work.
This plugin tracks damage per player while a heli or Bradley is alive, adds team totals when the target dies, and stores the winning team or player against each crate that spawns from the wreck for a configurable number of minutes. While a lock is active, CanLootEntity refuses access for anyone outside the owning team and sends a message naming the owner and the remaining time. Locks are held in oxide/data so a server restart does not open the crates early, and the config lets you set the lock duration per target type, the minimum damage required to claim, and whether team ownership is used.
The plugin code
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Heli Bradley Loot Lock", "RustPluginGenerator", "1.0.0")]
[Description("Locks Patrol Helicopter and Bradley APC crates to the top damager and their team for a configured time.")]
public class HeliBradleyLootLock : RustPlugin
{
private const string PermAdmin = "helibradleylootlock.admin";
private const string PermBypass = "helibradleylootlock.bypass";
private const string DataFile = "HeliBradleyLootLock";
private PluginConfig config;
private Timer pruneTimer;
// entityId -> (playerIdStr -> damage)
private readonly Dictionary<ulong, Dictionary<string, float>> damageByEntity = new Dictionary<ulong, Dictionary<string, float>>();
// target type -> pending winner info
private readonly Dictionary<string, PendingWinner> pendingWinners = new Dictionary<string, PendingWinner>();
// container entityId -> lock record
private readonly Dictionary<ulong, LockEntry> locks = new Dictionary<ulong, LockEntry>();
private class PluginConfig
{
[JsonProperty("Lock duration (minutes)")]
public float LockMinutes = 30f;
[JsonProperty("Lock applies to team")]
public bool TeamLocks = true;
[JsonProperty("Minimum damage percent of max health required")]
public float MinimumDamagePercent = 10f;
[JsonProperty("Seconds to wait for crates after a kill")]
public float CrateMatchSeconds = 30f;
[JsonProperty("Crate prefab name markers")]
public List<string> CrateMarkers = new List<string> { "heli_crate", "bradley_crate", "lootablecorpse" };
[JsonProperty("Chat prefix")]
public string Prefix = "<color=#ce422b>[LootLock]</color>";
}
private class PendingWinner
{
public string OwnerId;
public float CreatedAt;
}
private class LockEntry
{
public string OwnerId;
public string TargetType;
public DateTime ExpiresUtc;
}
private class StoredData
{
public Dictionary<ulong, LockEntry> Locks = new Dictionary<ulong, LockEntry>();
}
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>
{
["NoPermission"] = "You do not have permission to use this command.",
["Locked"] = "This crate belongs to {0}. You can loot it in {1}.",
["LockNotFound"] = "No lock exists for that entity ID.",
["NoActiveLocks"] = "There are no active loot locks.",
["Cleared"] = "Cleared {0} loot lock(s).",
["Unlocked"] = "Lock removed from entity {0}.",
["StatusLine"] = "{0} -> entity {1}, {2} remaining.",
["NoOwnerYet"] = "There are no loot locks yet."
}, 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);
permission.RegisterPermission(PermBypass, this);
}
private void OnServerInitialized()
{
LoadLocks();
pruneTimer = timer.Every(60f, () =>
{
if (PruneExpired()) SaveLocks();
});
}
private void LoadLocks()
{
locks.Clear();
var data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(DataFile);
if (data?.Locks == null) return;
foreach (var pair in data.Locks)
{
if (pair.Value == null || pair.Value.ExpiresUtc <= DateTime.UtcNow) continue;
locks[pair.Key] = pair.Value;
}
}
private void SaveLocks()
{
var data = new StoredData();
foreach (var pair in locks) data.Locks[pair.Key] = pair.Value;
Interface.Oxide.DataFileSystem.WriteObject(DataFile, data);
}
private bool PruneExpired()
{
var now = DateTime.UtcNow;
var toRemove = locks.Where(p => p.Value.ExpiresUtc <= now).Select(p => p.Key).ToList();
foreach (var id in toRemove) locks.Remove(id);
return toRemove.Count > 0;
}
private void OnServerSave() => SaveLocks();
private void Unload()
{
pruneTimer?.Destroy();
pruneTimer = null;
SaveLocks();
}
private static string TargetTypeOf(BaseCombatEntity entity)
{
if (entity is PatrolHelicopter) return "heli";
if (entity is BradleyAPC) return "bradley";
return null;
}
private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo hitInfo)
{
if (entity == null || hitInfo == null) return null;
var type = TargetTypeOf(entity);
if (type == null) return null;
var attacker = hitInfo.InitiatorPlayer;
if (attacker == null || attacker.IsNpc || attacker.userID.IsSteamId() == false) return null;
var damage = hitInfo.damageTypes?.Total() ?? 0f;
if (damage <= 0f) return null;
ulong id = entity.net.ID.Value;
Dictionary<string, float> table;
if (!damageByEntity.TryGetValue(id, out table))
{
table = new Dictionary<string, float>();
damageByEntity[id] = table;
}
string key = attacker.UserIDString;
float existing;
table.TryGetValue(key, out existing);
table[key] = existing + damage;
return null;
}
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
if (entity == null) return;
var type = TargetTypeOf(entity);
if (type == null) return;
ulong id = entity.net.ID.Value;
Dictionary<string, float> table;
if (!damageByEntity.TryGetValue(id, out table))
{
damageByEntity.Remove(id);
return;
}
damageByEntity.Remove(id);
var teamTotals = new Dictionary<string, float>();
var playerTotals = new Dictionary<string, float>();
foreach (var pair in table)
{
playerTotals[pair.Key] = pair.Value;
if (!config.TeamLocks) continue;
var player = BasePlayer.FindByID(ulong.Parse(pair.Key));
string ownerKey = player != null && player.currentTeam != 0
? "team_" + player.currentTeam
: pair.Key;
float sum;
teamTotals.TryGetValue(ownerKey, out sum);
teamTotals[ownerKey] = sum + pair.Value;
}
float best = 0f;
string bestOwner = null;
if (config.TeamLocks)
{
foreach (var pair in teamTotals)
{
if (pair.Value > best) { best = pair.Value; bestOwner = pair.Key; }
}
}
else
{
foreach (var pair in playerTotals)
{
if (pair.Value > best) { best = pair.Value; bestOwner = pair.Key; }
}
}
if (bestOwner == null) return;
float maxHealth = entity.MaxHealth();
if (maxHealth <= 0f) return;
float percent = (best / maxHealth) * 100f;
if (percent < config.MinimumDamagePercent) return;
pendingWinners[type] = new PendingWinner
{
OwnerId = bestOwner,
CreatedAt = Time.realtimeSinceStartup
};
}
private void OnEntitySpawned(BaseNetworkable entity)
{
if (entity == null || entity.IsDestroyed) return;
var name = entity.ShortPrefabName ?? "";
bool isCandidate = entity is LootableCorpse || entity is HackableLockedCrate ||
entity is StorageContainer;
if (!isCandidate) return;
if (!MatchesMarker(name)) return;
string type = null;
if (name.Contains("heli")) type = "heli";
else if (name.Contains("bradley")) type = "bradley";
if (type == null) return;
PendingWinner pending;
if (!pendingWinners.TryGetValue(type, out pending)) return;
if (Time.realtimeSinceStartup - pending.CreatedAt > config.CrateMatchSeconds)
{
pendingWinners.Remove(type);
return;
}
locks[entity.net.ID.Value] = new LockEntry
{
OwnerId = pending.OwnerId,
TargetType = type,
ExpiresUtc = DateTime.UtcNow.AddMinutes(config.LockMinutes)
};
pendingWinners.Remove(type);
SaveLocks();
}
private bool MatchesMarker(string name)
{
if (config.CrateMarkers == null) return false;
foreach (var marker in config.CrateMarkers)
{
if (!string.IsNullOrEmpty(marker) && name.Contains(marker)) return true;
}
return false;
}
private object CanLootEntity(BasePlayer player, StorageContainer storageContainer)
{
if (player == null || storageContainer == null) return null;
return CheckLoot(player, storageContainer);
}
private object CanLootEntity(BasePlayer player, LootableCorpse lootableCorpse)
{
if (player == null || lootableCorpse == null) return null;
return CheckLoot(player, lootableCorpse);
}
private object CheckLoot(BasePlayer player, BaseNetworkable container)
{
if (player.IsNpc) return null;
if (permission.UserHasPermission(player.UserIDString, PermBypass)) return null;
ulong id = container.net.ID.Value;
LockEntry entry;
if (!locks.TryGetValue(id, out entry)) return null;
if (entry.ExpiresUtc <= DateTime.UtcNow)
{
locks.Remove(id);
return null;
}
string ownerKey = config.TeamLocks && player.currentTeam != 0
? "team_" + player.currentTeam
: player.UserIDString;
if (ownerKey == entry.OwnerId) return null;
string ownerName = FormatOwner(entry.OwnerId);
var remaining = entry.ExpiresUtc - DateTime.UtcNow;
string time = remaining.TotalMinutes >= 1
? Math.Ceiling(remaining.TotalMinutes) + "m"
: Math.Ceiling(remaining.TotalSeconds) + "s";
player.ChatMessage(config.Prefix + " " + Msg("Locked", player.UserIDString, ownerName, time));
return false;
}
private string FormatOwner(string ownerId)
{
if (ownerId == null) return "unknown";
if (ownerId.StartsWith("team_"))
{
ulong team;
if (ulong.TryParse(ownerId.Substring(5), out team))
{
var t = RelationshipManager.ServerInstance.FindTeam(team);
if (t != null)
{
var leader = BasePlayer.FindByID(t.teamLeader);
return leader != null ? leader.displayName + "'s team" : "Team " + team;
}
}
return "Team " + ownerId.Substring(5);
}
ulong userId;
if (ulong.TryParse(ownerId, out userId))
{
var p = BasePlayer.FindByID(userId);
if (p != null) return p.displayName;
}
return ownerId;
}
private bool HasAdmin(BasePlayer player) =>
player != null && permission.UserHasPermission(player.UserIDString, PermAdmin);
[ChatCommand("hloot")]
private void CmdHloot(BasePlayer player, string command, string[] args)
{
if (!HasAdmin(player))
{
player.ChatMessage(config.Prefix + " " + Msg("NoPermission", player.UserIDString));
return;
}
string sub = args != null && args.Length > 0 ? args[0].ToLower() : "status";
if (sub == "status")
{
PruneExpired();
if (locks.Count == 0)
{
player.ChatMessage(config.Prefix + " " + Msg("NoOwnerYet", player.UserIDString));
return;
}
foreach (var pair in locks)
{
var remaining = pair.Value.ExpiresUtc - DateTime.UtcNow;
string time = Math.Max(0, Math.Ceiling(remaining.TotalSeconds)) + "s";
player.ChatMessage(config.Prefix + " " + Msg("StatusLine", player.UserIDString,
FormatOwner(pair.Value.OwnerId), pair.Key, time));
}
return;
}
if (sub == "unlock")
{
if (args.Length < 2)
{
player.ChatMessage(config.Prefix + " " + Msg("LockNotFound", player.UserIDString));
return;
}
ulong id;
if (!ulong.TryParse(args[1], out id) || !locks.Remove(id))
{
player.ChatMessage(config.Prefix + " " + Msg("LockNotFound", player.UserIDString));
return;
}
SaveLocks();
player.ChatMessage(config.Prefix + " " + Msg("Unlocked", player.UserIDString, id));
return;
}
if (sub == "clear")
{
int count = locks.Count;
locks.Clear();
SaveLocks();
player.ChatMessage(config.Prefix + " " + Msg("Cleared", player.UserIDString, count));
return;
}
player.ChatMessage(config.Prefix + " /hloot status | unlock <id> | clear");
}
}
}Hooks used: OnServerInitialized, OnServerSave, OnEntityTakeDamage, OnEntityDeath, OnEntitySpawned, CanLootEntity
Config file
Created automatically on first load at oxide/config/HeliBradleyLootLock.json.
{
"Lock duration (minutes)": 30,
"Lock applies to team": true,
"Minimum damage percent of max health required": 10,
"Seconds to wait for crates after a kill": 30,
"Crate prefab name markers": [
"heli_crate",
"bradley_crate",
"lootablecorpse"
],
"Chat prefix": "<color=#ce422b>[LootLock]</color>"
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/hloot status | Shows active loot locks with owner, target type and remaining seconds. | helibradleylootlock.admin |
/hloot unlock <entityId> | Removes the lock from a specific container by its network ID. | helibradleylootlock.admin |
/hloot clear | Removes every active lock and saves data. | helibradleylootlock.admin |
| Permission | Grants |
|---|---|
helibradleylootlock.admin | Grants /hloot status, /hloot unlock and /hloot clear. |
helibradleylootlock.bypass | Allows looting a locked crate regardless of ownership. |
How the code works
Recording damage per player
OnEntityTakeDamagereturnsnullimmediately unless the target is aPatrolHelicopterorBradleyAPC. It readshitInfo.InitiatorPlayer, ignores NPC attackers withIsNpc, and addshitInfo.damageTypes.Total()to a dictionary keyed by the attacker'suserID. Each helicopter or Bradley gets its own damage table keyed byentity.net.ID.Value, so two helicopters alive at once do not mix scores. The hook always returnsnullso base game damage still applies.Settling the winner on death
OnEntityDeathfires with the destroyedBaseCombatEntity. The plugin looks up the damage table for that entity, folds each player's damage into their team usingplayer.currentTeamandRelationshipManager.ServerInstance.FindTeam, and picks the highest total. If nobody reachesMinimumDamagePercentof the target's max health, no lock is created. The winner (team ID when team mode is on, otherwise the player's userID), the target type and the expiry time are stored in a pending record.Matching spawned crates to the kill
OnEntitySpawnedsees every new networkable. The plugin checks forLootableCorpseandHackableLockedCrate, plusStorageContainerentities whose prefab name contains the configured markers (heli_crate,bradley_crate,lootablecorpse). When a pending winner record exists for that target type and is younger thanCrateMatchSeconds, the new entity's network ID is added to the lock table with an expiry of now plusLockMinutes. Crates spawned by unrelated events that never match a pending record are untouched.Blocking loot in CanLootEntity
CanLootEntityreturnsnullfor anything that is not locked. Otherwise it resolves the looter's owning ID (team ID ifTeamLocksis on, else their userID), compares it to the stored owner, and if they differ, callsplayer.ChatMessagewith the localized refusal and returnsfalse. The message is formatted throughMsgso the owner and remaining time are shown. Admins withhelibradleylootlock.bypassare allowed through.Config keys that shape behavior
LockMinutessets how long a crate stays owned.TeamLockstoggles team-wide ownership.MinimumDamagePercentis the share of the target's max health a player or team must have dealt to claim.CrateMatchSecondsis how long after a kill the plugin keeps waiting for lootable crates to spawn.Prefixis prepended to every chat reply. Change these and reload the plugin; existing locks keep their original expiry.Persistence and cleanup
The lock table is a serializable class written to
oxide/data/HeliBradleyLootLock.jsonbyOnServerSaveand byUnload. When the plugin loads, it converts each entry's UTC expiry back into aDateTime; entries already in the past are dropped. Atimer.Every(60f, ...)prunes expired locks and saves.Unloaddestroys that timer and saves once more, so restarting the plugin never unlocks active crates mid-raid.
Install it on your server
Save HeliBradleyLootLock.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/HeliBradleyLootLock.json(Carbon:carbon/configs/HeliBradleyLootLock.json). After editing, reload it:oxide.reload HeliBradleyLootLock c.reload HeliBradleyLootLock # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default helibradleylootlock.admin c.grant group default helibradleylootlock.admin # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Lock by last hit instead of most damage
Rewards the player who lands the killing blow rather than the highest total damage.
Generate this versionAnnounce winners in global chat
Broadcasts the winning team and lock duration to the whole server.
Generate this versionAdd a cooldown between locks
Prevents the same team locking consecutive heli or Bradley drops.
Generate this version
FAQ
How do I install this on Oxide or Carbon?
Drop HeliBradleyLootLock.cs into oxide/plugins on Oxide, or carbon/plugins on a Carbon server (Carbon loads Oxide plugins unchanged). Reload the plugin once and edit oxide/config/HeliBradleyLootLock.json. Grant helibradleylootlock.admin to staff with oxide.grant user <id> helibradleylootlock.admin.
What happens to a lock if the owning team disbands?
The lock still checks the team ID stored when the crate was claimed. If the team no longer exists, RelationshipManager.ServerInstance.FindTeam returns null and the looter's team check fails, so nobody can open the crate until it expires. Use /hloot unlock <entityId> to open it early if needed.
Do crates spawned by plugins get locked?
Only crates that appear within CrateMatchSeconds of a real heli or Bradley death are locked. A crate spawned by another plugin outside that window, or with a prefab name that does not contain a configured marker, is left alone and behaves normally.
Does damage from turrets or NPCs count?
No. OnEntityTakeDamage reads hitInfo.InitiatorPlayer and skips anything with IsNpc. A turret's damage has no player initiator, so it is not credited to the turret owner. This keeps the winner tied to actual player involvement.
Why does the lock not apply to the body of the pilot?
Only LootableCorpse and crate-shaped containers listed in the crate matching step are considered loot crates. If you want pilot corpses locked too, add the matching prefab marker to the crate marker list in the config and reload the plugin.
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 pve plugins
- Building damage protectionBlocks and deployables only take damage from their owner or players authorized on the covering tool cupboard.OnEntityTakeDamage
- Player corpse loot protectionLocks a dead player's corpse and dropped backpack to the owner and their current team for a configurable number of minutes.OnServerInitializedOnEntitySpawnedCanLootEntity
- NPC corpse loot lockLocks NPC corpses to the killer and their team for a configurable number of seconds after death, then opens them to everyone.OnServerInitializedOnEntityDeathCanLootEntity
Last updated Oct 26, 2026.
Customize heli and bradley loot lock
Customize