How to make a Rust kill streak plugin (Oxide/Carbon)
Tracks consecutive player kills per user, announces configurable milestones in global chat and grants item rewards, then resets the streak when the player dies or disconnects. Uses OnEntityDeath and OnPlayerDisconnected.
Kill streak plugins give PvP a progression layer that vanilla Rust does not track: players want to know who is currently on a run and what the next milestone is worth. The naive version stores the count only in memory, which breaks when the server restarts or when a player quits and rejoins, and it often counts NPC kills or environmental deaths as PvP kills.
This plugin keeps a per-userID dictionary of streak counts and saves it to oxide/data so a restart does not wipe anyone's run. OnEntityDeath checks that the victim is a real, connected player and that the attacker is a different player, then increments the attacker, writes the new value and fires any milestone whose threshold was just reached. Milestones are defined in the config as a list of kill counts, reward shortnames and amounts. Dying or disconnecting removes the player from the dictionary entirely, so the next kill starts at one.
The plugin code
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("KillStreaks", "RustPluginGenerator", "1.0.0")]
[Description("Tracks consecutive PvP kills and rewards configurable milestones.")]
public class KillStreaks : RustPlugin
{
private const string PermUse = "killstreaks.use";
private const string PermAdmin = "killstreaks.admin";
private const string DataFile = "KillStreaks";
private class Milestone
{
[JsonProperty("Kills required")]
public int Kills { get; set; }
[JsonProperty("Reward shortname")]
public string RewardShortname { get; set; }
[JsonProperty("Reward amount")]
public int RewardAmount { get; set; }
}
private class PluginConfig
{
[JsonProperty("Announce every kill")]
public bool AnnounceEveryKill { get; set; }
[JsonProperty("Minimum streak to announce a lost streak")]
public int MinimumStreakToAnnounceLoss { get; set; }
[JsonProperty("Milestones")]
public List<Milestone> Milestones { get; set; }
}
private PluginConfig config;
private Dictionary<string, int> streaks = new Dictionary<string, int>();
protected override void LoadDefaultConfig()
{
config = new PluginConfig
{
AnnounceEveryKill = false,
MinimumStreakToAnnounceLoss = 5,
Milestones = new List<Milestone>
{
new Milestone { Kills = 5, RewardShortname = "scrap", RewardAmount = 50 },
new Milestone { Kills = 10, RewardShortname = "supply.signal", RewardAmount = 1 },
new Milestone { Kills = 15, RewardShortname = "rifle.ak", RewardAmount = 1 }
}
};
}
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>
{
["Milestone"] = "{0} reached a {1} kill streak!",
["EveryKill"] = "{0} is on a {1} kill streak.",
["StreakLost"] = "{0} lost a {1} kill streak.",
["YourStreak"] = "Your current kill streak: {0}",
["NoStreak"] = "You have no active kill streak.",
["TopHeader"] = "Top kill streaks:",
["TopEntry"] = "{0}. {1} - {2}",
["TopEmpty"] = "Nobody is on a kill streak right now.",
["ResetDone"] = "Cleared the streak for {0}.",
["ResetNotFound"] = "No streak found for {0}.",
["ResetUsage"] = "Usage: /streak reset <name or steamid>",
["NoPermission"] = "You do not have permission to use this command."
}, 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);
permission.RegisterPermission(PermAdmin, this);
}
private void OnServerInitialized()
{
var loaded = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, int>>(DataFile);
if (loaded != null) streaks = loaded;
}
private void Unload()
{
SaveData();
}
private void SaveData()
{
Interface.Oxide.DataFileSystem.WriteObject(DataFile, streaks);
}
private void OnEntityDeath(BasePlayer victim, HitInfo info)
{
if (victim == null || victim.IsNpc) return;
string victimId = victim.UserIDString;
int lostStreak = GetStreak(victimId);
if (lostStreak > 0)
{
streaks.Remove(victimId);
SaveData();
if (lostStreak >= config.MinimumStreakToAnnounceLoss)
Server.Broadcast(Msg("StreakLost", null, victim.displayName, lostStreak));
}
if (info == null) return;
BasePlayer attacker = info.InitiatorPlayer;
if (attacker == null || attacker.IsNpc || attacker == victim || !attacker.IsConnected) return;
if (attacker.userID == victim.userID) return;
string attackerId = attacker.UserIDString;
int newStreak = GetStreak(attackerId) + 1;
streaks[attackerId] = newStreak;
SaveData();
Milestone milestone = FindMilestone(newStreak);
if (milestone != null)
{
Server.Broadcast(Msg("Milestone", null, attacker.displayName, newStreak));
GiveReward(attacker, milestone);
}
else if (config.AnnounceEveryKill)
{
Server.Broadcast(Msg("EveryKill", null, attacker.displayName, newStreak));
}
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
if (player == null) return;
string userId = player.UserIDString;
if (streaks.Remove(userId)) SaveData();
}
private int GetStreak(string userId)
{
int value;
return streaks.TryGetValue(userId, out value) ? value : 0;
}
private Milestone FindMilestone(int kills)
{
if (config.Milestones == null) return null;
foreach (var milestone in config.Milestones)
{
if (milestone != null && milestone.Kills == kills) return milestone;
}
return null;
}
private void GiveReward(BasePlayer player, Milestone milestone)
{
if (string.IsNullOrEmpty(milestone.RewardShortname) || milestone.RewardAmount <= 0) return;
Item item = ItemManager.CreateByName(milestone.RewardShortname, milestone.RewardAmount);
if (item == null) return;
player.GiveItem(item);
}
[ChatCommand("streak")]
private void CmdStreak(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
if (args != null && args.Length > 0 && args[0].ToLower() == "top")
{
ShowTop(player);
return;
}
if (args != null && args.Length > 0 && args[0].ToLower() == "reset")
{
HandleReset(player, args);
return;
}
int streak = GetStreak(player.UserIDString);
SendReply(player, streak > 0 ? Msg("YourStreak", player.UserIDString, streak) : Msg("NoStreak", player.UserIDString));
}
private void ShowTop(BasePlayer player)
{
var list = new List<KeyValuePair<string, int>>(streaks);
list.Sort((a, b) => b.Value.CompareTo(a.Value));
if (list.Count == 0)
{
SendReply(player, Msg("TopEmpty", player.UserIDString));
return;
}
SendReply(player, Msg("TopHeader", player.UserIDString));
int shown = 0;
foreach (var entry in list)
{
if (shown >= 5) break;
string name = GetDisplayName(entry.Key);
SendReply(player, Msg("TopEntry", player.UserIDString, shown + 1, name, entry.Value));
shown++;
}
}
private string GetDisplayName(string userId)
{
BasePlayer found = BasePlayer.FindAwakeOrSleeping(userId);
return found != null ? found.displayName : userId;
}
private void HandleReset(BasePlayer player, string[] args)
{
if (!permission.UserHasPermission(player.UserIDString, PermAdmin))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
if (args.Length < 2)
{
SendReply(player, Msg("ResetUsage", player.UserIDString));
return;
}
string target = args[1];
string targetId = target;
BasePlayer found = BasePlayer.FindAwakeOrSleeping(target);
if (found != null) targetId = found.UserIDString;
if (streaks.Remove(targetId))
{
SaveData();
SendReply(player, Msg("ResetDone", player.UserIDString, target));
}
else
{
SendReply(player, Msg("ResetNotFound", player.UserIDString, target));
}
}
}
}Hooks used: OnServerInitialized, OnEntityDeath, OnPlayerDisconnected
Config file
Created automatically on first load at oxide/config/KillStreaks.json.
{
"Announce every kill": false,
"Minimum streak to announce a lost streak": 5,
"Milestones": [
{
"Kills required": 5,
"Reward shortname": "scrap",
"Reward amount": 50
},
{
"Kills required": 10,
"Reward shortname": "supply.signal",
"Reward amount": 1
},
{
"Kills required": 15,
"Reward shortname": "rifle.ak",
"Reward amount": 1
}
]
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/streak | Shows your current kill streak. | killstreaks.use |
/streak top | Lists the highest current kill streaks on the server. | killstreaks.use |
/streak reset <name or steamid> | Clears a player's streak. | killstreaks.admin |
| Permission | Grants |
|---|---|
killstreaks.use | Allows the /streak and /streak top commands. |
killstreaks.admin | Allows /streak reset to clear another player's streak. |
How the code works
Count only real PvP kills
OnEntityDeath(BaseCombatEntity entity, HitInfo info)fires for every entity death, including animals, scientists and structures. The plugin declares the parameter asBasePlayerinstead ofBaseCombatEntity, so Oxide's hook dispatch only calls it when the dead entity is a player; NPC deaths never reach the method. Inside it checks that the player is not an NPC and thatinfo.InitiatorPlayerexists, is connected and has a differentuserIDfrom the victim.Store streaks per userID
Counts live in a
Dictionary<string, int>keyed byplayer.UserIDString, loaded inOnServerInitializedthroughInterface.Oxide.DataFileSystem.ReadObject<Dictionary<string, int>>(DataFile)and written back inUnloadand after every change. Loading insideOnServerInitializedrather thanInitmeans the data file system is already available, and it keeps a restart from dropping a streak.Fire milestones from config
The config holds a
Milestoneslist of objects withKills,RewardShortnameandRewardAmount. After incrementing a streak the plugin looks for a milestone whoseKillsequals the new count and runs the announce-and-reward logic only then, so a player passing 5 does not re-trigger the 5 reward on kill 6. Unknown shortnames are caught becauseItemManager.CreateByNamereturns null for them.Announce to the server
Milestones use
Server.Broadcastwith text pulled from the language file, formatted with the player name and kill count. Ordinary kills stay silent by default (theAnnounceEveryKillconfig option turns them on) because broadcasting every kill on a busy server floods chat. Milestone broadcasts are rare enough that they never spam.Reset on death and disconnect
Death is handled inside the same
OnEntityDeathmethod: if the dead player had a streak of at leastMinimumStreakToAnnounceLoss, the server is told it ended. The entry is removed either way.OnPlayerDisconnected(BasePlayer player, string reason)does the same removal, which stops players from preserving a streak by leaving the server when they are about to die.Give the reward safely
Rewards are built with
ItemManager.CreateByName(config.RewardShortname, config.RewardAmount)and handed over withplayer.GiveItem(item), which drops the item at the player's feet if their inventory is full. The reward shortname and amount come from the matched milestone, so each threshold can give something different, for example 10 kills for a supply signal and 25 for an M249.
Install it on your server
Save KillStreaks.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/KillStreaks.json(Carbon:carbon/configs/KillStreaks.json). After editing, reload it:oxide.reload KillStreaks c.reload KillStreaks # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default killstreaks.use c.grant group default killstreaks.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Reward with Economics money
Pay a currency amount per milestone through the Economics plugin instead of items.
Generate this versionTeam streak tracking
Count kills for the whole team instead of one player.
Generate this versionStreak leaderboard UI
Show the top streaks in an on-screen CUI panel.
Generate this version
FAQ
Do NPC or animal kills count?
No. The hook parameter is declared as BasePlayer, so Oxide only calls the method when the dead entity is a player, and the attacker must also be a connected player. Scientist and animal deaths are ignored.
What happens to streaks on server restart?
They survive. The dictionary is written to oxide/data/KillStreaks.json on every change and loaded again in OnServerInitialized. A restart does not count as a death, so an active streak continues after the server comes back.
Why does quitting reset the streak?
OnPlayerDisconnected removes the player's entry. Without it, a player losing a fight could disconnect to keep the streak for their next session. Admins who want streaks to survive relogs can delete that removal line.
How do I change the milestone thresholds?
Edit oxide/config/KillStreaks.json. The Milestones array holds objects with Kills, RewardShortname and RewardAmount. Add, remove or reorder entries freely, then run oxide.reload KillStreaks to apply them.
How do I install it on Oxide or Carbon?
Drop the .cs file into oxide/plugins, where Carbon also reads it, and the plugin compiles on the next reload. The config file is created automatically on first load and permissions are registered when the plugin starts.
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 pvp plugins
- Combat blockTracks combat with the OnEntityTakeDamage hook and blocks a configurable list of chat commands while a player is in combat, using OnPlayerCommand toโฆOnEntityTakeDamageOnPlayerCommand
- DuelsAdds a player-vs-player duel system where /duel <player> sends a challenge, /duel accept starts it, and damage outside the duel pair is blocked.OnEntityTakeDamageOnEntityDeathOnPlayerDisconnected
- Friendly fire protectionBlocks damage between members of the same Rust team using OnEntityTakeDamage, with a permission whitelist and a /ff toggle for team leaders thatโฆOnEntityTakeDamageOnPlayerDisconnected
Last updated Oct 26, 2026.
Customize kill streaks
Customize