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
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.
{
"Maximum records kept": 2000,
"Seconds between data saves": 5,
"Print kills to server console": false
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/kills <player> | Prints the last 10 recorded kills where the named player was the killer or the victim. | killlog.view |
| Permission | Grants |
|---|---|
killlog.view | Grants access to the /kills chat command. |
How the code works
Capture the death with OnPlayerDeath
object OnPlayerDeath(BasePlayer player, HitInfo info)fires on the server whenever a player dies.playeris the victim,infois the hit that killed them. The hook returnsobjectso other plugins still run; returningnullkeeps default behaviour.player.displayName,info.InitiatorPlayer,info.WeaponPrefab.ShortPrefabName,info.ProjectileDistanceandinfo.isHeadshotare the values read here.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, ifinfo.InitiatorPlayeris null or an NPC, or if the killer is the victim. It also rejects any hit whoseinfo.damageTypes.GetMajorityDamageType()is not in the staticAllowedTypesset (Bullet, Slash, Stab, Blunt, Explosion), so fall, cold and radiation deaths never reach the log.Build a compact KillRecord
Each kill becomes a nested
KillRecordwithKillerName,KillerId,VictimName,VictimId,Weapon,Distance,Headshot,PositionandTime. Distance isMath.Round(info.ProjectileDistance, 1). Position is a comma-joined string ofMathf.RoundToIntx/y/z. Time isFacepunch.Math.Epoch.Current. The weapon falls back to"unknown"wheninfo.WeaponPrefabis null, so the record always serialises with every field set.Roll the log list and throttle saves
Records live in
List<KillRecord> records, loaded inOnServerInitializedwithInterface.Oxide.DataFileSystem.ReadObject. After appending, awhile (records.Count > config.MaxRecords) records.RemoveAt(0)drops the oldest. Writing on every kill is wasteful, so the code sets adirtyflag and starts atimer.Once(config.SaveDelay, ...)only ifsaveTimeris null.SaveDatawrites the list and clears the flag;Unloadsaves once more.Serve /kills through a permission check
[ChatCommand("kills")]receives(BasePlayer player, string command, string[] args). It replies withNoPermissionunlesspermission.UserHasPermission(player.UserIDString, PermView)passes, andUsagewhenargsis empty. The target is resolved withBasePlayer.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 withTake(10).Output through lang
LoadDefaultMessagesregisters four keys:NoPermission,Usage,NoKills,HeaderandLine({0} killed {1} with {2} at {3}m{4} ({5})). TheMsg(key, userId, params object[] args)helper wrapsstring.Format(lang.GetMessage(...)). In the loop,{4}holds" [HS]"for headshots and{5}holds the UTC time fromDateTimeOffset.FromUnixTimeSeconds(r.Time)formatted asMM-dd HH:mm. Chat is sent withSendReply.Keep config and data paths predictable
Data is written to
oxide/data/KillLog.jsonviaInterface.Oxide.DataFileSystem; config sits inoxide/config/KillLog.json. The nestedPluginConfighasMaximum records kept(default 2000),Seconds between data saves(default 5) andPrint kills to server console(default false). A broken config sets adirty-safe default with a singlePrintWarningduring load; nothing is printed from the kill path unlessLogToConsoleis on.
Install it on your server
Save KillLog.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/KillLog.json(Carbon:carbon/configs/KillLog.json). After editing, reload it:oxide.reload KillLog c.reload KillLog # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip: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 versionDamage leaderboard
Aggregate kills and headshots per player and expose /topkills for the current wipe.
Generate this versionKill 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.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More stats plugins
- Death cause statsThis plugin records every player death, classifies the cause from the HitInfo passed to OnPlayerDeath, and stores running totals per player soโฆOnServerInitializedOnPlayerDeath
- Gather statisticsTracks wood, stone, metal ore, sulfur ore and cloth gathered per player for the current wipe, persists the totals in oxide/data, and shows them withโฆOnServerInitializedOnDispenserGatheredOnCollectiblePickup
- Last seen commandRecords a timestamp for every player on disconnect and answers /seen <name> with a relative time such as '2 hours ago' or 'online now'.OnServerInitializedOnPlayerConnectedOnPlayerDisconnected
Last updated Oct 26, 2026.
Customize pvp kill log
Customize