How to make a Rust longest shot plugin (Oxide/Carbon)
Tracks each player's longest projectile kill distance and the server-wide record, saved to oxide/data, with a /longest chat command and an announcement when the server record is beaten. It uses OnPlayerDeath.
Rust gives no built-in way to know who landed the longest shot on your server. This plugin reads the death event, checks that the kill came from a projectile, and stores the distance in metres per player plus a single server record.
The hook receives the dying player and a HitInfo. The plugin takes the attacker from InitiatorPlayer, rejects non-projectile kills (melee, fall, decay, explosions without a weapon), and measures the distance between the attacker's eyes and the victim's position at the moment of death. Each player's best distance is kept in a data file under oxide/data, keyed by their Steam ID with the display name for readability. When a kill exceeds the stored server record, the plugin broadcasts the event to all players and updates the record. The /longest chat command shows the caller their own best kill and the current server record, so admins and players can check stats without console access.
The plugin code
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Longest Shot Tracker", "RustPluginGenerator", "1.0.0")]
[Description("Tracks each player's longest projectile kill distance and the server-wide record.")]
public class LongestShotTracker : RustPlugin
{
private const string PermUse = "longestshottracker.use";
private const string PermReset = "longestshottracker.reset";
private const string DataFileName = "LongestShotTracker";
private PluginConfig config;
private StoredData data;
private class PluginConfig
{
[JsonProperty("Announce Server Record")]
public bool AnnounceServerRecord { get; set; } = true;
[JsonProperty("Minimum Distance To Record")]
public float MinimumDistance { get; set; } = 50f;
}
private class PlayerRecord
{
[JsonProperty("Name")]
public string Name { get; set; } = "";
[JsonProperty("Distance")]
public float Distance { get; set; }
[JsonProperty("Victim Name")]
public string VictimName { get; set; } = "";
[JsonProperty("Timestamp")]
public long Timestamp { get; set; }
}
private class ServerRecord
{
[JsonProperty("Name")]
public string Name { get; set; } = "";
[JsonProperty("Distance")]
public float Distance { get; set; }
[JsonProperty("Victim Name")]
public string VictimName { get; set; } = "";
[JsonProperty("Timestamp")]
public long Timestamp { get; set; }
}
private class StoredData
{
[JsonProperty("Players")]
public Dictionary<ulong, PlayerRecord> Players { get; set; } = new Dictionary<ulong, PlayerRecord>();
[JsonProperty("Server")]
public ServerRecord Server { get; set; } = new ServerRecord();
}
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>
{
["NoRecord"] = "You have no projectile kill record yet. Land a kill from at least {0} m.",
["YourRecord"] = "Your longest shot: {0} m (victim: {1}).",
["ServerRecord"] = "Server record: {0} m by {1} (victim: {2}).",
["NoServerRecord"] = "No server record has been set yet.",
["RecordBroken"] = "{0} just set a new server record: {1} m kill on {2}!",
["Reset"] = "All longest shot records have been wiped.",
["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(PermReset, this);
}
private void OnServerInitialized()
{
data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(DataFileName) ?? new StoredData();
}
private void Unload()
{
SaveData();
}
private void SaveData()
{
if (data != null)
Interface.Oxide.DataFileSystem.WriteObject(DataFileName, data);
}
private object OnPlayerDeath(BasePlayer player, HitInfo info)
{
if (player == null || info == null) return null;
var attacker = info.InitiatorPlayer;
if (attacker == null || attacker == player) return null;
if (!info.IsProjectile()) return null;
float distance = Vector3.Distance(attacker.eyes.position, player.transform.position);
if (distance < config.MinimumDistance) return null;
distance = Mathf.Round(distance * 10f) / 10f;
if (data == null) data = new StoredData();
PlayerRecord existing;
if (!data.Players.TryGetValue(attacker.userID, out existing) || distance > existing.Distance)
{
data.Players[attacker.userID] = new PlayerRecord
{
Name = attacker.displayName,
Distance = distance,
VictimName = player.displayName,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
}
if (distance > data.Server.Distance)
{
data.Server = new ServerRecord
{
Name = attacker.displayName,
Distance = distance,
VictimName = player.displayName,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
if (config.AnnounceServerRecord)
Server.Broadcast(Msg("RecordBroken", null, attacker.displayName, distance.ToString("0.0"), player.displayName));
}
SaveData();
return null;
}
[ChatCommand("longest")]
private void CmdLongest(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
PlayerRecord own;
if (data != null && data.Players.TryGetValue(player.userID, out own))
SendReply(player, Msg("YourRecord", player.UserIDString, own.Distance.ToString("0.0"), own.VictimName));
else
SendReply(player, Msg("NoRecord", player.UserIDString, config.MinimumDistance.ToString("0")));
if (data != null && data.Server.Distance > 0f)
SendReply(player, Msg("ServerRecord", player.UserIDString, data.Server.Distance.ToString("0.0"), data.Server.Name, data.Server.VictimName));
else
SendReply(player, Msg("NoServerRecord", player.UserIDString));
}
[ConsoleCommand("longestshot.reset")]
private void CcmdReset(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (player != null && !permission.UserHasPermission(player.UserIDString, PermReset))
{
arg.ReplyWith(lang.GetMessage("NoPermission", this, player.UserIDString));
return;
}
data = new StoredData();
SaveData();
arg.ReplyWith(lang.GetMessage("Reset", this, player?.UserIDString));
}
}
}Hooks used: OnServerInitialized, OnPlayerDeath
Config file
Created automatically on first load at oxide/config/LongestShotTracker.json.
{
"Announce Server Record": true,
"Minimum Distance To Record": 50
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/longest | Shows your personal longest projectile kill distance and the current server record. | longestshottracker.use |
longestshot.reset | Clears all stored records (per-player and server-wide). | longestshottracker.reset |
| Permission | Grants |
|---|---|
longestshottracker.use | Allows players to run /longest. |
longestshottracker.reset | Allows use of the longestshot.reset console command. |
How the code works
Reading the death event
OnPlayerDeath(BasePlayer player, HitInfo info)fires for every death. The plugin returnsnullimmediately so vanilla behavior is untouched. It bails out ifinfois null, ifinfo.InitiatorPlayeris null (environmental or NPC deaths), or if attacker and victim are the same object. The attacker is taken frominfo.InitiatorPlayer; the victim is theplayerparameter.Filtering to projectile kills
Only projectile damage counts, so the plugin checks
info.IsProjectile(). That covers bullets and arrows fired from a weapon. Melee hits, explosions set off by hand, decay and fall damage are skipped, which keeps the leaderboard honest. Anything that does not return true fromIsProjectile()is ignored before distance is even measured.Measuring distance
Distance is computed with
Vector3.Distance(attacker.eyes.position, player.transform.position). Using the eye position rather than the attacker's feet matches what a shooter would call their range and stays stable whether the attacker is standing, crouching or on a ledge. The result is rounded withMathf.Round(distance * 10f) / 10ffor one decimal place and compared against the stored record.Data persistence
Records live in
oxide/data/LongestShotTracker.jsonthroughInterface.Oxide.DataFileSystem. TheStoredDataclass holds aDictionary<ulong, PlayerRecord>under thePlayerskey and a singleServerRecordunder theServerkey. The file is loaded inOnServerInitialized, written after every qualifying kill inSaveData, and written again inUnload, so a server restart keeps the data even after a crash.Announcing the server record
When a kill distance is greater than
data.Server.Distance, the plugin writes a newServerRecordand, ifAnnounce Server Recordis true in the config, sends theRecordBrokenmessage withServer.Broadcast. The config valueMinimum Distance To Record(default 50) filters out short-range fights; any kill below it returns early before the record check.The /longest command
[ChatCommand("longest")]requires thelongestshottracker.usepermission. It looks the caller up indata.Playersand formats eitherYourRecordorNoRecord, then always adds the server record usingServerRecordorNoServerRecord. Thelongestshot.resetconsole command clearsdatato a freshStoredDataand saves, so both per-player and server records reset together.
Install it on your server
Save LongestShotTracker.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/LongestShotTracker.json(Carbon:carbon/configs/LongestShotTracker.json). After editing, reload it:oxide.reload LongestShotTracker c.reload LongestShotTracker # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default longestshottracker.use c.grant group default longestshottracker.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Team leaderboard
Show a top-five list of players and their best distances instead of a private reply.
Generate this versionReward the record holder
Grant scrap or an Economy balance when a player beats their own record.
Generate this versionWipe-aware stats
Clear all records on new save so the leaderboard resets with the map.
Generate this version
FAQ
Does this work on Carbon?
Yes. Carbon loads standard Oxide plugins. Drop LongestShotTracker.cs into the plugins folder, reload the server, and it will read the same oxide/data file. No Oxide-specific APIs outside the documented hooks are used.
Why is a bow kill missing?
The plugin only accepts kills where HitInfo.IsProjectile() is true. If an arrow knocks the target off a ledge and they die from fall damage, the death is recorded as fall, not projectile, and does not qualify. Only the killing blow is inspected.
Can I change the minimum distance?
Yes. Open oxide/config/LongestShotTracker.json and set Minimum Distance To Record to whatever fits your map. Any kill closer than that value is ignored. On a small arena map 30โ50 is common; on large open maps 80โ120 filters out routine fights.
Where is the data file and how do I wipe it?
oxide/data/LongestShotTracker.json. It stores one entry per player with a qualifying kill plus the server record. Deleting the file resets everything on the next reload. Admins with the longestshottracker.reset permission can also run longestshot.reset in the server console.
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 longest kill shot
Customize