How to make a Rust NPC kill counter plugin (scientists and animals)
Tracks how many NPCs each player kills, split into scientist and animal totals, stores them in oxide/data, and announces every 100th kill. It uses the OnEntityDeath hook and the /npckills chat command.
Server owners often want a visible record of PvE activity: how many scientists a player has cleared, how many animals they have hunted. Rust does not keep this per player, and the vanilla kill feed disappears after a few seconds. The clean way to do it is to listen for entity deaths, check whether the dying entity is an NPC, and attribute the kill to the player in HitInfo.InitiatorPlayer.
This plugin implements that. OnEntityDeath fires for every death on the server, so the first job is cheap filtering: skip anything that is not a ScientistNPC or BaseAnimalNPC, skip missing hit info and missing attacker. Survivors and players are ignored. Counts are kept per player in a data file, split into scientists and animals, and /npckills shows the server, the caller, or a named player. Every 100th kill is announced to the whole server through Server.Broadcast, using a configurable message from the language file.
The plugin code
using System;
using System.Collections.Generic;
using Facepunch.Math;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("NpcKillCounter", "RustPluginGenerator", "1.0.0")]
[Description("Counts scientist and animal kills per player and announces every 100th kill.")]
public class NpcKillCounter : RustPlugin
{
private const string PermView = "npckillcounter.view";
private const string PermAnnounce = "npckillcounter.announce";
private PluginConfig config;
private StoredData data;
private Timer saveTimer;
private class PluginConfig
{
[JsonProperty("Save Interval Seconds")]
public float SaveIntervalSeconds { get; set; } = 300f;
[JsonProperty("Track Scientists")]
public bool TrackScientists { get; set; } = true;
[JsonProperty("Track Animals")]
public bool TrackAnimals { get; set; } = true;
[JsonProperty("Use Permission For Announce")]
public bool UsePermissionForAnnounce { get; set; } = false;
[JsonProperty("Announce Milestone")]
public int AnnounceMilestone { get; set; } = 100;
}
private class PlayerStats
{
[JsonProperty("Name")]
public string Name { get; set; }
[JsonProperty("Scientists")]
public int Scientists { get; set; }
[JsonProperty("Animals")]
public int Animals { get; set; }
[JsonProperty("Last Kill")]
public int LastKill { get; set; }
[JsonIgnore]
public int Total => Scientists + Animals;
}
private class StoredData
{
[JsonProperty("Players")]
public Dictionary<string, PlayerStats> Players { get; set; } = new Dictionary<string, PlayerStats>();
}
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>
{
["Your Stats"] = "NPC kills: {0} scientists, {1} animals (total {2}).",
["Other Stats"] = "{0}: {1} scientists, {2} animals (total {3}).",
["No Data"] = "No NPC kill data found for that player.",
["No Access"] = "You do not have permission to view other players.",
["Announce"] = "{0} reached {1} NPC kills ({2} scientists, {3} animals)!"
}, 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);
permission.RegisterPermission(PermAnnounce, this);
}
private void OnServerInitialized()
{
data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name) ?? new StoredData();
if (data.Players == null) data.Players = new Dictionary<string, PlayerStats>();
if (config.SaveIntervalSeconds > 0f)
saveTimer = timer.Every(config.SaveIntervalSeconds, SaveData);
}
private void Unload()
{
if (saveTimer != null && !saveTimer.Destroyed) saveTimer.Destroy();
SaveData();
}
private void SaveData()
{
if (data != null) Interface.Oxide.DataFileSystem.WriteObject(Name, data);
}
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
if (entity == null || info == null) return;
var attacker = info.InitiatorPlayer;
if (attacker == null || attacker.IsNpc || attacker.IsDead()) return;
bool isScientist = entity is ScientistNPC;
bool isAnimal = entity is BaseAnimalNPC;
if (isScientist && !config.TrackScientists) return;
if (isAnimal && !config.TrackAnimals) return;
if (!isScientist && !isAnimal) return;
PlayerStats stats;
if (!data.Players.TryGetValue(attacker.UserIDString, out stats))
{
stats = new PlayerStats();
data.Players[attacker.UserIDString] = stats;
}
stats.Name = attacker.displayName;
stats.LastKill = Epoch.Current;
if (isScientist) stats.Scientists++;
else stats.Animals++;
int total = stats.Total;
int milestone = config.AnnounceMilestone;
if (milestone <= 0 || total % milestone != 0) return;
if (config.UsePermissionForAnnounce && !permission.UserHasPermission(attacker.UserIDString, PermAnnounce)) return;
Server.Broadcast(Msg("Announce", null, stats.Name, total, stats.Scientists, stats.Animals));
}
[ChatCommand("npckills")]
private void CmdNpcKills(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (args == null || args.Length == 0)
{
SendStats(player, player.UserIDString, true);
return;
}
if (!permission.UserHasPermission(player.UserIDString, PermView))
{
player.ChatMessage(Msg("No Access", player.UserIDString));
return;
}
string query = string.Join(" ", args).Trim();
string targetId = ResolvePlayerId(query);
if (string.IsNullOrEmpty(targetId) || !data.Players.ContainsKey(targetId))
{
player.ChatMessage(Msg("No Data", player.UserIDString));
return;
}
SendStats(player, targetId, false);
}
private string ResolvePlayerId(string query)
{
if (string.IsNullOrEmpty(query)) return null;
ulong rawId;
if (ulong.TryParse(query, out rawId))
return rawId.ToString();
var target = BasePlayer.FindAwakeOrSleeping(query);
if (target != null) return target.UserIDString;
foreach (var pair in data.Players)
{
if (string.Equals(pair.Value.Name, query, StringComparison.OrdinalIgnoreCase))
return pair.Key;
}
return null;
}
private void SendStats(BasePlayer viewer, string targetId, bool self)
{
PlayerStats stats;
if (!data.Players.TryGetValue(targetId, out stats))
{
viewer.ChatMessage(Msg("No Data", viewer.UserIDString));
return;
}
if (self)
viewer.ChatMessage(Msg("Your Stats", viewer.UserIDString, stats.Scientists, stats.Animals, stats.Total));
else
viewer.ChatMessage(Msg("Other Stats", viewer.UserIDString, stats.Name ?? targetId, stats.Scientists, stats.Animals, stats.Total));
}
}
}Hooks used: OnServerInitialized, OnEntityDeath
Config file
Created automatically on first load at oxide/config/NpcKillCounter.json.
{
"Save Interval Seconds": 300,
"Track Scientists": true,
"Track Animals": true,
"Use Permission For Announce": false,
"Announce Milestone": 100
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/npckills | Shows your own scientist and animal kill totals. | Everyone |
/npckills <name or steamid> | Shows another player's totals. Requires the view permission. | Everyone |
| Permission | Grants |
|---|---|
npckillcounter.view | Allows using /npckills with another player's name or SteamID. |
npckillcounter.announce | When granted to a player, their kill counts are included in the 100th kill broadcast. Grant to all players or use the config broadcast setting. |
How the code works
Filter deaths down to NPC kills
OnEntityDeath(BaseCombatEntity entity, HitInfo info)runs for every creature death. The guard chain is short:entity == nullreturns immediately, theninfo?.InitiatorPlayeris checked so environmental deaths (fall, fire, decay) are ignored. The entity is then cast withas ScientistNPCandas BaseAnimalNPC. If neither matches, the hook returns without touching the data file. This keeps the hot path cheap, since the method can be called hundreds of times per hour on a busy server.Attribute and count the kill
The attacker comes from
info.InitiatorPlayer, which is null when a turret or trap dealt the damage. The plugin handles that by falling back toinfo.Initiator as BasePlayeronly when the direct initiator is a player; turret kills are not credited. The SteamID string fromUserIDStringbecomes the data key. Inside the entry,ScientistsorAnimalsis incremented based on which cast succeeded, and aLastKillunix timestamp is written fromFacepunch.Math.Epoch.Current.Persist counts in oxide/data
The data class
StoredDataholds aDictionary<string, PlayerStats>. It is loaded inOnServerInitializedwithInterface.Oxide.DataFileSystem.ReadObject<StoredData>(Name)and written back onUnloadand on atimer.Everysave interval from the config (Save Interval Seconds, default 300). The player name is refreshed on each kill so the/npckills <name>lookup can match display names even after a rename.Announce every 100th kill
After incrementing, the total for that player is computed as
Scientists + Animals. Iftotal % 100 == 0, the plugin callsServer.Broadcastwith theAnnouncemessage from the language file, formatted with the player name and their scientist/animal split. Announcements are gated by theUse Permission For Announceconfig option: when enabled, only players withnpckillcounter.announcetrigger broadcasts, which is useful on servers where grinding animals should not spam chat.Read the counts back with /npckills
[ChatCommand("npckills")]handles no arguments by showing the caller's stats. With one argument it joins the remaining args into a name, resolves a player withBasePlayer.FindAwakeOrSleeping, and checkspermission.UserHasPermissionagainstnpckillcounter.view. SteamID input is also accepted because the data key is the ID string. Unknown names reply with theNo Datamessage so admins are not left guessing whether the lookup failed or the plugin is broken.Config and message layout
PluginConfigexposesSave Interval Seconds,Track Scientists,Track Animals, andUse Permission For Announce. Turning tracking off for one category means that kill type is skipped before the counter increments, which is handy if you only want a scientist leaderboard. All player-facing text lives inLoadDefaultMessagesunder keys likeYour Stats,Other Stats, andAnnounce, so admins can translate or reword without editing the DLL or the plugin file.
Install it on your server
Save NpcKillCounter.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/NpcKillCounter.json(Carbon:carbon/configs/NpcKillCounter.json). After editing, reload it:oxide.reload NpcKillCounter c.reload NpcKillCounter # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default npckillcounter.view c.grant group default npckillcounter.view # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Leaderboard command
Add /npckills top to print the ten highest scientist killers.
Generate this versionReward every milestone
Give scrap or call Economics when a player hits 100 kills.
Generate this versionWeekly reset
Wipe the counter automatically every server wipe.
Generate this version
FAQ
Do turret and trap kills count?
No. The plugin credits only kills where HitInfo.InitiatorPlayer resolves to a player. Turret, flame turret, landmine and decay deaths fall through and are not counted. If you want trap kills counted, extend the hook to read info.Initiator.OwnerID and look up the owner as a player.
Are Bradley APC or Helicopter kills included?
Not in this version. They are separate entity types (BradleyAPC, PatrolHelicopter) and would need their own counters. The hook fires for them like any other death, so adding a third branch and a config toggle is a small change if you want boss kills tracked separately.
Where is the data stored and when is it written?
Counts live in oxide/data/NpcKillCounter.json, loaded at startup and saved on unload plus a timer interval set by Save Interval Seconds. If the server crashes between saves, only kills since the last write are lost.
How do I install this on Oxide or Carbon?
Drop the .cs file into oxide/plugins and let the server compile it, or oxide.reload NpcKillCounter after an edit. Carbon loads the same file from carbon/plugins. The config appears at oxide/config/NpcKillCounter.json; the default data file is created on the first kill.
Can players see each other's counts?
Only with the npckillcounter.view permission. Without it, /npckills prints the caller's own totals and /npckills <name> replies with a no-access message. Grant the permission to staff groups only if you want members to stay private.
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 npcs plugins
- Animal spawn commandAdds a permission-gated /animal <species> [count] chat command that spawns vanilla animals in front of the player, with a per-command cap, aโฆ
- Custom NPC lootRewrites the items inside corpses left by configured NPCs, using a weighted per-prefab loot table.OnServerInitializedOnCorpsePopulate
- NPC free zonesDefines named world zones where newly spawned scientists and animals are deleted immediately.OnEntitySpawned
Last updated Oct 26, 2026.
Customize npc kill counter
Customize