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
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.
{
"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
| Command | What it does | Permission |
|---|---|---|
/radar [radius] | Lists players within the radius (default from config) and draws their names in world for a few seconds. | adminradar.use |
| Permission | Grants |
|---|---|
adminradar.use | Grants access to the /radar command and the in-world name overlay. |
How the code works
Config and the radius clamp
PluginConfigholdsDefault Radius,Maximum Radius,Overlay Seconds,Label Font SizeandLabel Colour (RGBA). The chat command reads an optional numeric argument; a parsed value is clamped between 1 andMaximum Radius, a value that fails to parse replies with theUsagemessage, and no argument falls back toDefault Radius. This keeps a moderator from typing/radar 5000and 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.Collecting candidates
Both
BasePlayer.activePlayerListandBasePlayer.sleepingPlayerListare iterated. Each entry is skipped when it is null, refers to the admin (by reference oruserID), or hasIsNpcset. Distance comes fromVector3.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, andIsSleeping()sets the marker in the output.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 againstVector3.up. Projecting the target offset onto those two gives forward and side components, andMathf.Atan2converts 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.Output and sorting
Results go into a small list of a nested
RadarEntryclass 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 theHeaderlang entry with the count and radius, then one line per player rendered with theLinekey plus optionalSleepingsuffix. All player-facing text is registered inLoadDefaultMessagesvialang.RegisterMessages, so server owners can translate or reformat it without touching the DLL.In-world labels with DDraw
After the chat reply,
DDraw.Clear(admin)runs and thenDDraw.Textis called immediately for each entry at the player's head position with the configured colour and font size. Atimer.Onceset toOverlay Secondslater callsDDraw.Clear(admin)if the admin is still connected. The timer is stored in aTimerfield and destroyed inUnload, along withDDraw.Clearfor every active player, so a reload mid-overlay does not leave floating names behind.Permissions, quality of life and unload
adminradar.useis registered inInitand checked withpermission.UserHasPermissionbefore anything runs. Unauthorised callers get theNo Permissionlang key.Unloaddestroys the stored timer if present, iteratesBasePlayer.activePlayerList, callsDDraw.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
Save AdminRadar.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/AdminRadar.json(Carbon:carbon/configs/AdminRadar.json). After editing, reload it:oxide.reload AdminRadar c.reload AdminRadar # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip: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 versionTeam and clan awareness
Marks players who share the admin's team or an Oxide clan instead of listing them blindly.
Generate this versionConsole 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.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More admin tools plugins
- Admin command loggerRecords every console command executed by a player with admin rights into an oxide/data file, optionally echoes it to the server console, and showsโฆOnServerInitializedOnServerCommand
- Admin entity removerAdds an admin-only /kill command that raycast-targets the entity you are looking at and removes it, plus /killall <shortprefabname> which asks for aโฆOnServerInitialized
- Admin teleport commandsAdds permission-gated /tp, /tphere and /tppos chat commands that resolve players by partial name and confirm the move in chat.
Last updated Oct 26, 2026.
Customize admin radar
Customize