Skip to content
RustPluginGenerator

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

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

LongestShotTracker.json
{
  "Announce Server Record": true,
  "Minimum Distance To Record": 50
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/longestShows your personal longest projectile kill distance and the current server record.longestshottracker.use
longestshot.resetClears all stored records (per-player and server-wide).longestshottracker.reset
Permissions
PermissionGrants
longestshottracker.useAllows players to run /longest.
longestshottracker.resetAllows use of the longestshot.reset console command.

How the code works

  1. Reading the death event

    OnPlayerDeath(BasePlayer player, HitInfo info) fires for every death. The plugin returns null immediately so vanilla behavior is untouched. It bails out if info is null, if info.InitiatorPlayer is null (environmental or NPC deaths), or if attacker and victim are the same object. The attacker is taken from info.InitiatorPlayer; the victim is the player parameter.

  2. 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 from IsProjectile() is ignored before distance is even measured.

  3. 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 with Mathf.Round(distance * 10f) / 10f for one decimal place and compared against the stored record.

  4. Data persistence

    Records live in oxide/data/LongestShotTracker.json through Interface.Oxide.DataFileSystem. The StoredData class holds a Dictionary<ulong, PlayerRecord> under the Players key and a single ServerRecord under the Server key. The file is loaded in OnServerInitialized, written after every qualifying kill in SaveData, and written again in Unload, so a server restart keeps the data even after a crash.

  5. Announcing the server record

    When a kill distance is greater than data.Server.Distance, the plugin writes a new ServerRecord and, if Announce Server Record is true in the config, sends the RecordBroken message with Server.Broadcast. The config value Minimum Distance To Record (default 50) filters out short-range fights; any kill below it returns early before the record check.

  6. The /longest command

    [ChatCommand("longest")] requires the longestshottracker.use permission. It looks the caller up in data.Players and formats either YourRecord or NoRecord, then always adds the server record using ServerRecord or NoServerRecord. The longestshot.reset console command clears data to a fresh StoredData and saves, so both per-player and server records reset together.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip: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 version
  • Reward the record holder

    Grant scrap or an Economy balance when a player beats their own record.

    Generate this version
  • Wipe-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.

Prompt: Create a Rust Oxide plugin that tracks each player's longest projectile kill distance and the server record using OnPlayerDeath, stores data in oxide/data, and adds a /longest chat command showing personal and server bests.
Customize for my server

Edit the prompt on the next screen. 2 free plugins a month, no card.

Last updated Oct 26, 2026.

Customize longest kill shot

Customize