Skip to content
RustPluginGenerator

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

NpcKillCounter.cs
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.

NpcKillCounter.json
{
  "Save Interval Seconds": 300,
  "Track Scientists": true,
  "Track Animals": true,
  "Use Permission For Announce": false,
  "Announce Milestone": 100
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/npckillsShows your own scientist and animal kill totals.Everyone
/npckills <name or steamid>Shows another player's totals. Requires the view permission.Everyone
Permissions
PermissionGrants
npckillcounter.viewAllows using /npckills with another player's name or SteamID.
npckillcounter.announceWhen 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

  1. Filter deaths down to NPC kills

    OnEntityDeath(BaseCombatEntity entity, HitInfo info) runs for every creature death. The guard chain is short: entity == null returns immediately, then info?.InitiatorPlayer is checked so environmental deaths (fall, fire, decay) are ignored. The entity is then cast with as ScientistNPC and as 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.

  2. 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 to info.Initiator as BasePlayer only when the direct initiator is a player; turret kills are not credited. The SteamID string from UserIDString becomes the data key. Inside the entry, Scientists or Animals is incremented based on which cast succeeded, and a LastKill unix timestamp is written from Facepunch.Math.Epoch.Current.

  3. Persist counts in oxide/data

    The data class StoredData holds a Dictionary<string, PlayerStats>. It is loaded in OnServerInitialized with Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name) and written back on Unload and on a timer.Every save 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.

  4. Announce every 100th kill

    After incrementing, the total for that player is computed as Scientists + Animals. If total % 100 == 0, the plugin calls Server.Broadcast with the Announce message from the language file, formatted with the player name and their scientist/animal split. Announcements are gated by the Use Permission For Announce config option: when enabled, only players with npckillcounter.announce trigger broadcasts, which is useful on servers where grinding animals should not spam chat.

  5. 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 with BasePlayer.FindAwakeOrSleeping, and checks permission.UserHasPermission against npckillcounter.view. SteamID input is also accepted because the data key is the ID string. Unknown names reply with the No Data message so admins are not left guessing whether the lookup failed or the plugin is broken.

  6. Config and message layout

    PluginConfig exposes Save Interval Seconds, Track Scientists, Track Animals, and Use 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 in LoadDefaultMessages under keys like Your Stats, Other Stats, and Announce, so admins can translate or reword without editing the DLL or the plugin file.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip: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.

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.

Prompt: Create a Rust Oxide plugin that counts NPC kills per player, splits scientists and animals, saves to oxide/data, announces every 100th kill server-wide, and provides /npckills with a view 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 npc kill counter

Customize