Skip to content
RustPluginGenerator

How to make a Rust PvP kill log plugin (rolling history + /kills lookup)

Records every PvP kill (killer, victim, weapon, distance, headshot flag, position, timestamp) into a capped data file and lets admins read any player's last 10 kills with /kills <name>. Built on the OnPlayerDeath hook.

Vanilla Rust tells you almost nothing about who killed whom after the corpse is gone. If you run a PvP server you eventually want a history: which weapon killed a player, at what range, whether it was a headshot, and where on the map it happened. The OnPlayerDeath hook gives you all of that in one call, because the HitInfo that arrives with the death carries the attacker, the weapon prefab and the hit position.

This plugin writes one compact record per PvP kill into a rolling list stored in oxide/data/KillLog.json. When the list grows past MaxRecords, the oldest entries are dropped so the file never grows without bound. Admins then run /kills <player> to print the last ten kills of that player, as killer or victim. NPC deaths and suicides are ignored.

The plugin code

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

namespace Oxide.Plugins
{
    [Info("KillLog", "RustPluginGenerator", "1.0.0")]
    [Description("Records PvP kills into a rolling data file and lets admins read a player's history.")]
    public class KillLog : RustPlugin
    {
        private const string PermView = "killlog.view";

        private class PluginConfig
        {
            [JsonProperty("Maximum records kept")]
            public int MaxRecords { get; set; } = 2000;

            [JsonProperty("Seconds between data saves")]
            public float SaveDelay { get; set; } = 5f;

            [JsonProperty("Print kills to server console")]
            public bool LogToConsole { get; set; } = false;
        }

        private class KillRecord
        {
            public string KillerName { get; set; }
            public string KillerId { get; set; }
            public string VictimName { get; set; }
            public string VictimId { get; set; }
            public string Weapon { get; set; }
            public float Distance { get; set; }
            public bool Headshot { get; set; }
            public string Position { get; set; }
            public int Time { get; set; }
        }

        private PluginConfig config;
        private List<KillRecord> records = new List<KillRecord>();
        private Timer saveTimer;
        private bool dirty;

        private static readonly HashSet<Rust.DamageType> AllowedTypes = new HashSet<Rust.DamageType>
        {
            Rust.DamageType.Bullet,
            Rust.DamageType.Slash,
            Rust.DamageType.Stab,
            Rust.DamageType.Blunt,
            Rust.DamageType.Explosion
        };

        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.",
                ["Usage"] = "Usage: /kills <player name or steam id>",
                ["NoKills"] = "No recorded kills for {0}.",
                ["Header"] = "Last {0} kills for {1}:",
                ["Line"] = "{0} killed {1} with {2} at {3}m{4} ({5})"
            }, 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(PermView, this);
        }

        private void OnServerInitialized()
        {
            records = Interface.Oxide.DataFileSystem.ReadObject<List<KillRecord>>(Name) ?? new List<KillRecord>();
        }

        private void Unload()
        {
            saveTimer?.Destroy();
            saveTimer = null;
            SaveData();
        }

        private object OnPlayerDeath(BasePlayer player, HitInfo info)
        {
            if (player == null || player.IsNpc || info == null) return null;

            var killer = info.InitiatorPlayer;
            if (killer == null || killer.IsNpc || killer == player) return null;
            if (!AllowedTypes.Contains(info.damageTypes.GetMajorityDamageType())) return null;

            string weapon = info.WeaponPrefab != null ? info.WeaponPrefab.ShortPrefabName : "unknown";
            var pos = player.transform.position;

            var record = new KillRecord
            {
                KillerName = killer.displayName,
                KillerId = killer.UserIDString,
                VictimName = player.displayName,
                VictimId = player.UserIDString,
                Weapon = weapon,
                Distance = (float)Math.Round(info.ProjectileDistance, 1),
                Headshot = info.isHeadshot,
                Position = $"{Mathf.RoundToInt(pos.x)},{Mathf.RoundToInt(pos.y)},{Mathf.RoundToInt(pos.z)}",
                Time = Facepunch.Math.Epoch.Current
            };

            records.Add(record);
            while (records.Count > config.MaxRecords)
            {
                records.RemoveAt(0);
            }

            if (config.LogToConsole)
            {
                PrintWarning($"{record.KillerName} killed {record.VictimName} with {record.Weapon}");
            }

            dirty = true;
            if (saveTimer == null)
            {
                saveTimer = timer.Once(config.SaveDelay, () =>
                {
                    saveTimer = null;
                    SaveData();
                });
            }

            return null;
        }

        private void SaveData()
        {
            if (!dirty && saveTimer != null) return;
            Interface.Oxide.DataFileSystem.WriteObject(Name, records);
            dirty = false;
        }

        [ChatCommand("kills")]
        private void CmdKills(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermView))
            {
                SendReply(player, Msg("NoPermission", player.UserIDString));
                return;
            }

            if (args == null || args.Length == 0)
            {
                SendReply(player, Msg("Usage", player.UserIDString));
                return;
            }

            string query = args[0];
            var target = BasePlayer.FindAwakeOrSleeping(query);
            string targetId = target != null ? target.UserIDString : query;
            string targetName = target != null ? target.displayName : query;

            var matches = records.Where(r =>
                r.KillerId == targetId || r.VictimId == targetId ||
                string.Equals(r.KillerName, targetName, StringComparison.OrdinalIgnoreCase) ||
                string.Equals(r.VictimName, targetName, StringComparison.OrdinalIgnoreCase))
                .Reverse()
                .Take(10)
                .ToList();

            if (matches.Count == 0)
            {
                SendReply(player, Msg("NoKills", player.UserIDString, targetName));
                return;
            }

            SendReply(player, Msg("Header", player.UserIDString, matches.Count, targetName));
            foreach (var r in matches)
            {
                string hs = r.Headshot ? " [HS]" : string.Empty;
                string time = DateTimeOffset.FromUnixTimeSeconds(r.Time).UtcDateTime.ToString("MM-dd HH:mm");
                SendReply(player, Msg("Line", player.UserIDString, r.KillerName, r.VictimName, r.Weapon, r.Distance, hs, time));
            }
        }
    }
}

Hooks used: OnServerInitialized, OnPlayerDeath

Config file

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

KillLog.json
{
  "Maximum records kept": 2000,
  "Seconds between data saves": 5,
  "Print kills to server console": false
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/kills <player>Prints the last 10 recorded kills where the named player was the killer or the victim.killlog.view
Permissions
PermissionGrants
killlog.viewGrants access to the /kills chat command.

How the code works

  1. Capture the death with OnPlayerDeath

    object OnPlayerDeath(BasePlayer player, HitInfo info) fires on the server whenever a player dies. player is the victim, info is the hit that killed them. The hook returns object so other plugins still run; returning null keeps default behaviour. player.displayName, info.InitiatorPlayer, info.WeaponPrefab.ShortPrefabName, info.ProjectileDistance and info.isHeadshot are the values read here.

  2. Filter to real PvP kills

    The hook fires for NPCs, suicide and environmental deaths too. The code bails out if player == null, player.IsNpc, info == null, if info.InitiatorPlayer is null or an NPC, or if the killer is the victim. It also rejects any hit whose info.damageTypes.GetMajorityDamageType() is not in the static AllowedTypes set (Bullet, Slash, Stab, Blunt, Explosion), so fall, cold and radiation deaths never reach the log.

  3. Build a compact KillRecord

    Each kill becomes a nested KillRecord with KillerName, KillerId, VictimName, VictimId, Weapon, Distance, Headshot, Position and Time. Distance is Math.Round(info.ProjectileDistance, 1). Position is a comma-joined string of Mathf.RoundToInt x/y/z. Time is Facepunch.Math.Epoch.Current. The weapon falls back to "unknown" when info.WeaponPrefab is null, so the record always serialises with every field set.

  4. Roll the log list and throttle saves

    Records live in List<KillRecord> records, loaded in OnServerInitialized with Interface.Oxide.DataFileSystem.ReadObject. After appending, a while (records.Count > config.MaxRecords) records.RemoveAt(0) drops the oldest. Writing on every kill is wasteful, so the code sets a dirty flag and starts a timer.Once(config.SaveDelay, ...) only if saveTimer is null. SaveData writes the list and clears the flag; Unload saves once more.

  5. Serve /kills through a permission check

    [ChatCommand("kills")] receives (BasePlayer player, string command, string[] args). It replies with NoPermission unless permission.UserHasPermission(player.UserIDString, PermView) passes, and Usage when args is empty. The target is resolved with BasePlayer.FindAwakeOrSleeping(args[0]); if that returns null the raw argument is used as ID and name. Records are filtered where either ID matches or either name equals the query, reversed, and limited with Take(10).

  6. Output through lang

    LoadDefaultMessages registers four keys: NoPermission, Usage, NoKills, Header and Line ({0} killed {1} with {2} at {3}m{4} ({5})). The Msg(key, userId, params object[] args) helper wraps string.Format(lang.GetMessage(...)). In the loop, {4} holds " [HS]" for headshots and {5} holds the UTC time from DateTimeOffset.FromUnixTimeSeconds(r.Time) formatted as MM-dd HH:mm. Chat is sent with SendReply.

  7. Keep config and data paths predictable

    Data is written to oxide/data/KillLog.json via Interface.Oxide.DataFileSystem; config sits in oxide/config/KillLog.json. The nested PluginConfig has Maximum records kept (default 2000), Seconds between data saves (default 5) and Print kills to server console (default false). A broken config sets a dirty-safe default with a single PrintWarning during load; nothing is printed from the kill path unless LogToConsole is on.

Install it on your server

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

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

Variations

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

  • Kill feed broadcast

    Push every recorded PvP kill to chat as a formatted one-liner instead of only writing data.

    Generate this version
  • Damage leaderboard

    Aggregate kills and headshots per player and expose /topkills for the current wipe.

    Generate this version
  • Kill streak rewards

    Reward players with scrap when they reach a streak of PvP kills without dying.

    Generate this version

FAQ

How do I install this on Oxide or Carbon?

Save the file as KillLog.cs under oxide/plugins/. Oxide and Carbon both pick it up on the next server start or after oxide.reload KillLog. No external plugins are required.

Why are some kills missing from the log?

Only kills where the killer is a real player count. Deaths by NPCs, suicide, fall damage, decay and any hit whose majority damage type is not Bullet, Slash, Stab, Blunt or Explosion are skipped. Projectile kills from sentry turrets are skipped too, because InitiatorPlayer is null.

Does the data file grow without limit?

No. Maximum records kept caps the list and the oldest entries are dropped from the front whenever the cap is exceeded. On a busy server with 2000 records the file stays well under a megabyte, which keeps oxide/data fast to load on restart.

Can I query by Steam ID instead of player name?

Yes. /kills 7656119... works because the lookup first tries BasePlayer.FindAwakeOrSleeping, then falls back to matching the raw ID string against KillerId and VictimId in the records. This lets you look up players who are not online.

How do I wipe the log cleanly?

Stop the server or run oxide.unload KillLog, delete oxide/data/KillLog.json, then reload. The plugin recreates the file on the next save. For a per-wipe reset, schedule deletion of the data file alongside your map wipe.

Customize it for your server

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

Prompt: Write an Oxide Rust plugin called KillLog that records PvP kills via OnPlayerDeath into oxide/data, keeping the last 2000 records, with /kills <player> for admins (permission killlog.view) showing that player's last 10 kills.
Customize for my server

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

Last updated Oct 26, 2026.

Customize pvp kill log

Customize