Skip to content
RustPluginGenerator

How to make a Rust raid logger plugin (explosive hit tracking)

Logs explosive damage on player buildings to oxide/data, merges nearby hits into single raid events, and lists them with /raids for admins. Uses OnEntityTakeDamage filtered to BuildingBlock hits with Explosion damage.

Server owners rarely know who raided which base and when. Raid reports come from players, arrive late, and the evidence is gone after despawn or wipe. The plugin below records every explosive hit on a player-owned building block, then merges hits that land close together in time and space into one raid event so a 20-rocket assault shows up as one line, not twenty.

It hooks OnEntityTakeDamage and only reacts when the victim is a BuildingBlock, the blow comes from an explosion, and a player can be identified as the attacker. Each accepted hit is folded into the nearest open event within the configured radius and time window, or starts a new one. Events are written to oxide/data/RaidLogger.json, capped by Max Stored Events, and /raids prints the most recent entries. A Log To Console option mirrors new events to the server console for RCON monitoring.

The plugin code

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

namespace Oxide.Plugins
{
    [Info("Raid Logger", "RustPluginGenerator", "1.0.0")]
    [Description("Logs explosive damage on player buildings to a data file and lists recent raids in game.")]
    public class RaidLogger : RustPlugin
    {
        private const string PermUse = "raidlogger.use";

        private PluginConfig config;
        private StoredData data;

        private class PluginConfig
        {
            [JsonProperty("Merge Radius (metres)")]
            public float MergeRadius = 25f;

            [JsonProperty("Merge Window Seconds")]
            public int MergeWindowSeconds = 300;

            [JsonProperty("Max Stored Events")]
            public int MaxStoredEvents = 500;

            [JsonProperty("Minimum Hits Per Event")]
            public int MinimumHits = 1;

            [JsonProperty("Log To Console")]
            public bool LogToConsole = true;
        }

        private class RaidEvent
        {
            [JsonProperty("Attacker Id")]
            public string AttackerId;

            [JsonProperty("Attacker Name")]
            public string AttackerName;

            [JsonProperty("Defender Id")]
            public string DefenderId;

            [JsonProperty("Defender Name")]
            public string DefenderName;

            [JsonProperty("Grid")]
            public string Grid;

            [JsonProperty("Position")]
            public Vector3 Position;

            [JsonProperty("Weapon")]
            public string Weapon;

            [JsonProperty("First Seen")]
            public long FirstSeen;

            [JsonProperty("Last Seen")]
            public long LastSeen;

            [JsonProperty("Hits")]
            public int Hits;
        }

        private class StoredData
        {
            [JsonProperty("Raids")]
            public List<RaidEvent> Raids = new List<RaidEvent>();
        }

        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.",
                ["NoRaids"] = "No raids have been logged yet.",
                ["Header"] = "Recent raids ({0} shown of {1}):",
                ["Entry"] = "{0}. {1} ago | {2} grid | {3} ({4}) hit {5} ({6}) with {7}, {8} hits",
                ["InvalidCount"] = "Invalid count. Usage: /raids [count]"
            }, 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(PermUse, this);
        }

        private void OnServerInitialized()
        {
            data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name) ?? new StoredData();
        }

        private void Unload()
        {
            if (data != null)
                Interface.Oxide.DataFileSystem.WriteObject(Name, data);
        }

        private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo hitInfo)
        {
            if (entity == null || hitInfo == null || hitInfo.damageTypes == null) return null;

            var block = entity as BuildingBlock;
            if (block == null) return null;

            if (hitInfo.damageTypes.GetMajorityDamageType() != Rust.DamageType.Explosion) return null;

            BasePlayer attacker = hitInfo.InitiatorPlayer;
            if (attacker == null && hitInfo.Initiator is BasePlayer initiatorPlayer)
                attacker = initiatorPlayer;

            if (attacker == null || attacker.IsNpc) return null;

            string defenderId = ResolveOwnerId(block);
            if (string.IsNullOrEmpty(defenderId)) return null;

            string weapon = hitInfo.WeaponPrefab != null
                ? hitInfo.WeaponPrefab.ShortPrefabName
                : hitInfo.Weapon != null ? hitInfo.Weapon.ShortPrefabName : "explosive";

            RecordRaid(block, attacker, defenderId, weapon);
            return null;
        }

        private string ResolveOwnerId(BuildingBlock block)
        {
            if (block.buildingID != 0)
            {
                foreach (var ent in BaseNetworkable.serverEntities)
                {
                    var priv = ent as BuildingPrivlidge;
                    if (priv != null && priv.buildingID == block.buildingID)
                        return priv.OwnerID.ToString();
                }
            }

            return block.OwnerID != 0 ? block.OwnerID.ToString() : null;
        }

        private void RecordRaid(BuildingBlock block, BasePlayer attacker, string defenderId, string weapon)
        {
            if (data == null) data = new StoredData();

            Vector3 pos = block.transform.position;
            long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

            RaidEvent match = null;
            foreach (var ev in data.Raids)
            {
                if (ev.DefenderId != defenderId) continue;
                if (ev.AttackerId != attacker.UserIDString) continue;
                if (now - ev.LastSeen > config.MergeWindowSeconds) continue;
                if (Vector3.Distance(ev.Position, pos) > config.MergeRadius) continue;
                match = ev;
                break;
            }

            if (match != null)
            {
                match.Hits++;
                match.LastSeen = now;
                match.Position = pos;
                return;
            }

            var raid = new RaidEvent
            {
                AttackerId = attacker.UserIDString,
                AttackerName = attacker.displayName,
                DefenderId = defenderId,
                DefenderName = "Unknown",
                Grid = GridReference(pos),
                Position = pos,
                Weapon = weapon,
                FirstSeen = now,
                LastSeen = now,
                Hits = 1
            };

            var defender = BasePlayer.FindAwakeOrSleeping(defenderId);
            if (defender != null)
                raid.DefenderName = defender.displayName;

            data.Raids.Add(raid);

            while (data.Raids.Count > Math.Max(1, config.MaxStoredEvents))
                data.Raids.RemoveAt(0);

            Interface.Oxide.DataFileSystem.WriteObject(Name, data);

            if (config.LogToConsole)
                Puts(string.Format("[Raid] {0} ({1}) hit {2} ({3}) at {4} grid with {5}",
                    attacker.displayName, attacker.UserIDString, raid.DefenderName, defenderId, raid.Grid, weapon));
        }

        private static string GridReference(Vector3 position)
        {
            const float cellSize = 146.5f;
            const float worldSize = 4500f;

            int x = Mathf.FloorToInt((position.x + worldSize / 2f) / cellSize);
            int z = Mathf.FloorToInt((position.z + worldSize / 2f) / cellSize);

            x = Mathf.Clamp(x, 0, 30);
            z = Mathf.Clamp(z, 0, 30);

            char column = (char)('A' + x);
            return column + z.ToString();
        }

        [ChatCommand("raids")]
        private void CmdRaids(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;

            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                player.ChatMessage(Msg("NoPermission", player.UserIDString));
                return;
            }

            if (data == null || data.Raids.Count == 0)
            {
                player.ChatMessage(Msg("NoRaids", player.UserIDString));
                return;
            }

            int count = 10;
            if (args != null && args.Length > 0 && (!int.TryParse(args[0], out count) || count <= 0))
            {
                player.ChatMessage(Msg("InvalidCount", player.UserIDString));
                return;
            }

            count = Mathf.Clamp(count, 1, data.Raids.Count);
            var recent = data.Raids.Skip(data.Raids.Count - count).Reverse().ToList();

            player.ChatMessage(Msg("Header", player.UserIDString, count, data.Raids.Count));

            long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
            for (int i = 0; i < recent.Count; i++)
            {
                var ev = recent[i];
                long ago = Math.Max(0, now - ev.FirstSeen);
                string time = ago < 60 ? ago + "s" : (ago / 60) + "m";
                player.ChatMessage(Msg("Entry", player.UserIDString,
                    i + 1, time, ev.Grid, ev.AttackerName, ev.AttackerId,
                    ev.DefenderName, ev.DefenderId, ev.Weapon, ev.Hits));
            }
        }
    }
}

Hooks used: OnServerInitialized, OnEntityTakeDamage

Config file

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

RaidLogger.json
{
  "Merge Radius (metres)": 25,
  "Merge Window Seconds": 300,
  "Max Stored Events": 500,
  "Minimum Hits Per Event": 1,
  "Log To Console": true
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/raids [count]Lists the most recent raid events: attacker, victim, grid, weapon and time. Defaults to 10 events.raidlogger.use
Permissions
PermissionGrants
raidlogger.useAllows running /raids. Admins do not bypass this check, grant it explicitly.

How the code works

  1. Filtering for building damage

    OnEntityTakeDamage(BaseCombatEntity entity, HitInfo hitInfo) fires for every damageable entity, so the first job is narrowing it. The plugin returns immediately if entity, hitInfo or hitInfo.damageTypes is null, then casts the entity with as BuildingBlock into a local variable block. If that cast fails the hook returns and the default damage handling continues untouched. This single overload covers every block type because they all derive from BaseCombatEntity.

  2. Checking damage type and attacker

    hitInfo.damageTypes.GetMajorityDamageType() must equal Rust.DamageType.Explosion; any other majority type returns early. The attacker is read from hitInfo.InitiatorPlayer, and if that is null the plugin checks hitInfo.Initiator and calls GetOwnerPlayer() on it, which catches explosives owned by a player that did not hit the block directly. NPC attackers are dropped with an attacker.IsNpc check so scientist raids do not flood the log.

  3. Locating the base owner

    Ownership is resolved in ResolveOwnerId. When block.buildingID is non-zero the plugin iterates BaseNetworkable.serverEntities, casts each entry to BuildingPrivlidge, and returns OwnerID of the privilege whose buildingID matches. If no privilege is found it falls back to block.OwnerID. When both are zero the method returns null and OnEntityTakeDamage skips the hit, so unowned world structures stay out of the log.

  4. Merging hits into raid events

    RecordRaid walks the stored list looking for an event that shares DefenderId and AttackerId, whose LastSeen is within Merge Window Seconds of now, and whose saved Position is within Merge Radius metres of the hit. A match increments Hits, refreshes LastSeen and updates Position. Anything else appends a fresh RaidEvent with FirstSeen, grid reference, weapon shortname taken from WeaponPrefab or Weapon, and the attacker's display name.

  5. Persistence and the /raids command

    On OnServerInitialized the plugin reads StoredData from Interface.Oxide.DataFileSystem using the plugin name as the file key. Every new event is written straight back and the list is trimmed to Max Stored Events by removing the oldest entries first. /raids [count] is a [ChatCommand] that checks raidlogger.use, validates the optional count, takes the last N events reversed, and prints them through lang keys with grid, elapsed time, attacker, victim, weapon and hit count.

Install it on your server

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

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

Variations

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

  • Discord webhook alerts

    Push each new raid event to a Discord channel as soon as it is created.

    Generate this version
  • Raid summary on reconnect

    Tell the defending player how much explosive damage their base took while offline.

    Generate this version
  • Reward the first raid of the day

    Give the attacking player a scrap reward for their first recorded raid event each day.

    Generate this version

FAQ

Why does one raid show up as several events?

Events merge only when the attacker, the defender and the hit position all match within Merge Window Seconds and Merge Radius. Raiding several bases at once, or hitting opposite corners of a very large compound, produces separate entries. Raise the radius or window if you want broader merging.

Does it log door and box damage too?

No. The hook only records hits where the victim entity casts to BuildingBlock, so walls, foundations and door frames count but deployables such as doors, boxes and turrets do not. If you need deployables logged, add a second check for the specific entity types you care about.

How much performance does it cost?

The hook runs on every damage event, but most return early after the null checks, the BuildingBlock cast or the damage type comparison. The privilege scan over BaseNetworkable.serverEntities only runs for hits that already passed those filters, so the cost is limited to real explosive damage.

Will it work on Carbon?

Yes. Copy the .cs file into the Oxide plugins folder or Carbon's plugin folder, let it compile, then edit oxide/config/RaidLogger.json. The data file is created at oxide/data/RaidLogger.json when the first raid is recorded.

Does the log survive a server wipe?

The data file persists across restarts, but many hosts delete oxide/data during a full wipe, which clears the history. Keep Max Stored Events modest so old raids fall off naturally, and back up the file before wiping if you need the records.

Customize it for your server

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

Prompt: Write a Rust Oxide plugin named RaidLogger that logs explosive damage on player building blocks to oxide/data, merges nearby hits into raid events, and adds /raids listing recent raids for the raidlogger.use permission.
Customize for my server

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

Last updated Oct 26, 2026.

Customize raid logger

Customize