Skip to content
RustPluginGenerator

How to make a Rust admin radar plugin (proximity player list)

A permission-gated admin tool that lists every player within a configurable radius with distance, compass direction and sleep state, then draws their names in world for a few seconds. It uses one chat command and a short timer, with no hooks required.

Moderators responding to a report usually want one answer fast: who is nearby. Vanilla Rust gives you nothing for that; you either fly around blind or dig through logs after the fact. The plugin below adds a /radar chat command (optional [radius] argument) available only to a permission you grant. It walks BasePlayer.activePlayerList and BasePlayer.sleepingPlayerList, filters by distance from the admin, and sorts the nearest first. Each line shows the name, rounded distance in metres, an eight-point compass bearing relative to where the admin is looking, and a sleeping marker. The same players are then drawn in world for a few seconds with DDraw.Text so names sit on top of bodies. Radius, display time and text size live in the config, and the command accepts an optional override that is clamped to a hard maximum.

The plugin code

AdminRadar.cs
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Admin Radar", "RustPluginGenerator", "1.0.0")]
    [Description("Lists nearby players for admins and draws their names in world for a few seconds.")]
    public class AdminRadar : RustPlugin
    {
        private const string PermUse = "adminradar.use";
        private const string OverlayKey = "adminradar.overlay";

        private PluginConfig config;
        private Timer overlayTimer;

        private class PluginConfig
        {
            [JsonProperty("Default Radius")]
            public float DefaultRadius = 100f;

            [JsonProperty("Maximum Radius")]
            public float MaximumRadius = 500f;

            [JsonProperty("Overlay Seconds")]
            public float OverlaySeconds = 8f;

            [JsonProperty("Label Font Size")]
            public int LabelFontSize = 14;

            [JsonProperty("Label Colour (RGBA)")]
            public string LabelColour = "1 0.85 0.2 1";
        }

        private class RadarEntry
        {
            public BasePlayer Player;
            public float Distance;
            public string Bearing;
            public bool Sleeping;
        }

        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>
            {
                ["No Permission"] = "You do not have permission to use the radar.",
                ["Usage"] = "Usage: /radar [radius]",
                ["Header"] = "Radar: {0} player(s) within {1}m",
                ["Line"] = "{0} - {1}m {2}{3}",
                ["Sleeping"] = " (sleeping)",
                ["None"] = "Radar: no players within {0}m"
            }, 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);
        }

        private void Unload()
        {
            if (overlayTimer != null)
            {
                overlayTimer.Destroy();
                overlayTimer = null;
            }

            foreach (var player in BasePlayer.activePlayerList)
            {
                if (player == null) continue;
                DDraw.Clear(player);
            }
        }

        [ChatCommand("radar")]
        private void CmdRadar(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                SendReply(player, Msg("No Permission", player.UserIDString));
                return;
            }

            float radius = config.DefaultRadius;
            if (args != null && args.Length > 0 && !float.TryParse(args[0], out radius))
            {
                SendReply(player, Msg("Usage", player.UserIDString));
                return;
            }

            radius = Mathf.Clamp(radius, 1f, Mathf.Max(1f, config.MaximumRadius));

            var entries = Collect(player, radius);
            if (entries.Count == 0)
            {
                SendReply(player, Msg("None", player.UserIDString, Mathf.RoundToInt(radius)));
                return;
            }

            SendReply(player, Msg("Header", player.UserIDString, entries.Count, Mathf.RoundToInt(radius)));
            foreach (var entry in entries)
            {
                SendReply(player, Msg("Line", player.UserIDString,
                    entry.Player.displayName,
                    Mathf.RoundToInt(entry.Distance),
                    entry.Bearing,
                    entry.Sleeping ? Msg("Sleeping", player.UserIDString) : string.Empty));
            }

            ShowOverlay(player, entries);
        }

        private List<RadarEntry> Collect(BasePlayer admin, float radius)
        {
            var vector = admin.transform.position;
            var forward = admin.eyes.HeadForward();
            forward.y = 0f;
            if (forward.sqrMagnitude < 0.001f) forward = admin.transform.forward;
            forward.Normalize();
            var right = Vector3.Cross(Vector3.up, forward).normalized;

            var results = new List<RadarEntry>();

            foreach (var target in BasePlayer.activePlayerList) AddCandidate(results, admin, target, vector, forward, right, radius);
            foreach (var target in BasePlayer.sleepingPlayerList) AddCandidate(results, admin, target, vector, forward, right, radius);

            results.Sort((a, b) => a.Distance.CompareTo(b.Distance));
            return results;
        }

        private void AddCandidate(List<RadarEntry> results, BasePlayer admin, BasePlayer target, Vector3 origin, Vector3 forward, Vector3 right, float radius)
        {
            if (target == null || target == admin || target.IsNpc || target.userID == admin.userID) return;

            var distance = Vector3.Distance(target.transform.position, origin);
            if (distance > radius) return;

            var offset = target.transform.position - origin;
            offset.y = 0f;
            var degrees = Mathf.Atan2(Vector3.Dot(offset, right), Vector3.Dot(offset, forward)) * Mathf.Rad2Deg;

            results.Add(new RadarEntry
            {
                Player = target,
                Distance = distance,
                Bearing = Bearing(degrees),
                Sleeping = target.IsSleeping()
            });
        }

        private static string Bearing(float degrees)
        {
            if (degrees < 0f) degrees += 360f;

            if (degrees < 22.5f || degrees >= 337.5f) return "N";
            if (degrees < 67.5f) return "NE";
            if (degrees < 112.5f) return "E";
            if (degrees < 157.5f) return "SE";
            if (degrees < 202.5f) return "S";
            if (degrees < 247.5f) return "SW";
            if (degrees < 292.5f) return "W";
            return "NW";
        }

        // DDraw.Text on this Rust build takes (BasePlayer, Vector3, string text, Color color, int fontSize, bool ...)
        // so pass a UnityEngine.Color and omit the extra float parameter.
        private static Color ParseColour(string value)
        {
            if (string.IsNullOrEmpty(value)) return Color.yellow;
            var parts = value.Split(' ');
            if (parts.Length < 3) return Color.yellow;
            float r, g, b, a = 1f;
            if (!float.TryParse(parts[0], out r)) r = 1f;
            if (!float.TryParse(parts[1], out g)) g = 1f;
            if (!float.TryParse(parts[2], out b)) b = 0f;
            if (parts.Length > 3) float.TryParse(parts[3], out a);
            return new Color(r, g, b, a);
        }

        private void ShowOverlay(BasePlayer admin, List<RadarEntry> entries)
        {
            if (overlayTimer != null)
            {
                overlayTimer.Destroy();
                overlayTimer = null;
            }

            DDraw.Clear(admin);

            var colour = ParseColour(config.LabelColour);

            foreach (var entry in entries)
            {
                if (entry.Player == null || entry.Player.IsDestroyed) continue;
                var head = entry.Player.transform.position + new Vector3(0f, 2.1f, 0f);
                DDraw.Text(admin, head, entry.Player.displayName, colour, config.LabelFontSize, true);
            }

            overlayTimer = timer.Once(Mathf.Max(1f, config.OverlaySeconds), () =>
            {
                if (admin != null && admin.IsConnected) DDraw.Clear(admin);
                overlayTimer = null;
            });
        }
    }
}

Config file

Created automatically on first load at oxide/config/AdminRadar.json.

AdminRadar.json
{
  "Default Radius": 100,
  "Maximum Radius": 500,
  "Overlay Seconds": 8,
  "Label Font Size": 14,
  "Label Colour (RGBA)": "1 0.85 0.2 1"
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/radar [radius]Lists players within the radius (default from config) and draws their names in world for a few seconds.adminradar.use
Permissions
PermissionGrants
adminradar.useGrants access to the /radar command and the in-world name overlay.

How the code works

  1. Config and the radius clamp

    PluginConfig holds Default Radius, Maximum Radius, Overlay Seconds, Label Font Size and Label Colour (RGBA). The chat command reads an optional numeric argument; a parsed value is clamped between 1 and Maximum Radius, a value that fails to parse replies with the Usage message, and no argument falls back to Default Radius. This keeps a moderator from typing /radar 5000 and pulling several hundred names at once. Text size and colour for the in-world labels are also config fields, so servers with high resolution overrides do not need a recompile.

  2. Collecting candidates

    Both BasePlayer.activePlayerList and BasePlayer.sleepingPlayerList are iterated. Each entry is skipped when it is null, refers to the admin (by reference or userID), or has IsNpc set. Distance comes from Vector3.Distance(target.transform.position, admin.transform.position) and anything outside the radius is dropped. Sleeping players are included because a sleeping body is exactly what a raider or a lurking cheater tends to be, and IsSleeping() sets the marker in the output.

  3. Direction relative to the admin's view

    A world bearing is not much help when you are facing one way. The plugin takes admin.eyes.HeadForward(), projects it onto the XZ plane, and builds a right-hand vector with a cross product against Vector3.up. Projecting the target offset onto those two gives forward and side components, and Mathf.Atan2 converts that into degrees. The result is mapped to eight compass points, so the list reads like "Bob - 42m NE" from the admin's current heading rather than an absolute north value.

  4. Output and sorting

    Results go into a small list of a nested RadarEntry class holding the player reference, distance, bearing string and sleeping flag. Sorting by distance keeps the closest contacts at the top where they matter. Chat output is assembled from the Header lang entry with the count and radius, then one line per player rendered with the Line key plus optional Sleeping suffix. All player-facing text is registered in LoadDefaultMessages via lang.RegisterMessages, so server owners can translate or reformat it without touching the DLL.

  5. In-world labels with DDraw

    After the chat reply, DDraw.Clear(admin) runs and then DDraw.Text is called immediately for each entry at the player's head position with the configured colour and font size. A timer.Once set to Overlay Seconds later calls DDraw.Clear(admin) if the admin is still connected. The timer is stored in a Timer field and destroyed in Unload, along with DDraw.Clear for every active player, so a reload mid-overlay does not leave floating names behind.

  6. Permissions, quality of life and unload

    adminradar.use is registered in Init and checked with permission.UserHasPermission before anything runs. Unauthorised callers get the No Permission lang key. Unload destroys the stored timer if present, iterates BasePlayer.activePlayerList, calls DDraw.Clear(player) for each, and returns. Because the plugin reacts only to a typed command, it is idle on an empty server and adds no per-frame overhead.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip:oxide.grant group default adminradar.use c.grant group default adminradar.use # Carbon

Variations

Each one opens the generator with a ready prompt you can tweak.

  • Draw a line to each target

    Adds DDraw lines from the admin to every listed player for the same duration.

    Generate this version
  • Team and clan awareness

    Marks players who share the admin's team or an Oxide clan instead of listing them blindly.

    Generate this version
  • Console output for RCON

    Prints the same report to the server console so a remote moderator can read it over RCON.

    Generate this version

FAQ

Does this plugin work on Carbon?

Yes. Carbon loads standard Oxide plugins, so the single .cs file drops into oxide/plugins and runs unchanged. The DDraw helper ships with both frameworks, which is why the overlay works without any extra dependency.

Will it let ordinary players see hidden names?

No. The /radar command is behind the adminradar.use permission registered in Init, and the check happens before any player list is built. Grant it with oxide.grant user <steamid> adminradar.use or oxide.grant group admin adminradar.use.

Why do sleeping players appear in the results?

Sleeping bodies are covered by BasePlayer.sleepingPlayerList, which the plugin scans in addition to activePlayerList. IsSleeping() sets the marker in the output so you can tell an idle player from an active one, which is exactly what you want when checking a base for body bags or offline occupants.

How do I change how long names stay on screen?

Edit Overlay Seconds in oxide/config/AdminRadar.json and reload the plugin so the new config is read. The value is then used on the next /radar call.

Does the plugin have any hooks?

None. It reacts only to the /radar chat command, so it costs nothing while idle. If you later want the overlay to update continuously, you would have to add a repeating timer and destroy it in Unload.

Customize it for your server

Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.

Prompt: Build a Rust Oxide admin radar plugin with an adminradar.use permission and a /radar [radius] chat command that lists nearby players with distance, compass direction and sleeping status, plus DDraw name labels for a few seconds.
Customize for my server

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

Last updated Oct 26, 2026.

Customize admin radar

Customize