How to make a Rust safe PvE zones plugin (spherical, no damage)
Creates configurable spherical safe zones where damage between players and to buildings is cancelled, with entry and exit chat notices checked on a repeating timer. It uses OnEntityTakeDamage plus a timer loop.
Rust's built-in safe zones only cover a handful of monuments, and they do not stop damage to player structures at all. If you run a PvP server with a starter area, an event arena, or a trading post, you need your own rules.
This plugin keeps a list of zones in its config: a center coordinate and a radius. Every few seconds it checks each connected player's position against every zone, and sends a chat line when a player enters or leaves one. While a player is inside, OnEntityTakeDamage returns false when the victim is a player or a building block inside that same sphere. That covers bullets, melee, explosions and fire, because the hook fires before any damage is applied.
The zones are managed in game with an admin chat command, and everything is stored in oxide/config/SafePveZones.json.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Safe PvE Zones", "RustPluginGenerator", "1.0.0")]
[Description("Blocks player and building damage inside configurable spherical zones and announces entry and exit.")]
public class SafePveZones : RustPlugin
{
private const string PermAdmin = "safepvezones.admin";
private PluginConfig config;
private readonly List<Zone> zones = new List<Zone>();
private readonly Dictionary<ulong, string> playerZones = new Dictionary<ulong, string>();
private Timer checkTimer;
private class Zone
{
public string Name;
public Vector3 Center;
public float RadiusSqr;
}
private class ZoneEntry
{
[JsonProperty("Name")]
public string Name = "safe";
[JsonProperty("X")]
public float X;
[JsonProperty("Y")]
public float Y;
[JsonProperty("Z")]
public float Z;
[JsonProperty("Radius")]
public float Radius = 50f;
}
private class PluginConfig
{
[JsonProperty("Check Interval Seconds")]
public float CheckIntervalSeconds = 2f;
[JsonProperty("Zones")]
public List<ZoneEntry> Zones = new List<ZoneEntry>();
}
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>
{
["Entered"] = "You entered the safe zone '{0}'. PvP and building damage are disabled here.",
["Left"] = "You left the safe zone '{0}'. You are vulnerable again.",
["Added"] = "Zone '{0}' created with radius {1}.",
["Removed"] = "Zone '{0}' removed.",
["NotFound"] = "No zone named '{0}' exists.",
["ListHeader"] = "{0} safe zone(s) configured:",
["ListEntry"] = "- {0}: {1} (radius {2})",
["NoPermission"] = "You do not have permission to use this command.",
["Usage"] = "Usage: /pvezone add <name> <radius> | /pvezone remove <name> | /pvezone list",
["InvalidRadius"] = "Radius must be a number greater than 0.",
["Empty"] = "No zones are configured."
}, this);
}
private string Msg(string key, string userId = null, params object[] args)
{
return string.Format(lang.GetMessage(key, this, userId), args);
}
private void Init()
{
permission.RegisterPermission(PermAdmin, this);
}
private void OnServerInitialized()
{
RebuildZones();
StartTimer();
}
private void Unload()
{
if (checkTimer != null)
{
checkTimer.Destroy();
checkTimer = null;
}
playerZones.Clear();
}
private void RebuildZones()
{
zones.Clear();
if (config?.Zones == null) return;
foreach (var entry in config.Zones)
{
if (entry == null || entry.Radius <= 0f) continue;
zones.Add(new Zone
{
Name = entry.Name,
Center = new Vector3(entry.X, entry.Y, entry.Z),
RadiusSqr = entry.Radius * entry.Radius
});
}
}
private void StartTimer()
{
if (checkTimer != null) checkTimer.Destroy();
checkTimer = timer.Every(Mathf.Max(0.5f, config.CheckIntervalSeconds), CheckPlayers);
}
private Zone TryGetZone(Vector3 position)
{
for (int i = 0; i < zones.Count; i++)
{
var zone = zones[i];
if ((position - zone.Center).sqrMagnitude <= zone.RadiusSqr) return zone;
}
return null;
}
private void CheckPlayers()
{
foreach (var player in BasePlayer.activePlayerList)
{
if (player == null || player.IsNpc || !player.IsConnected) continue;
if (player.IsSleeping() || player.IsDead()) continue;
var zone = TryGetZone(player.transform.position);
string previous;
playerZones.TryGetValue(player.userID.Get(), out previous);
if (zone == null)
{
if (previous != null)
{
playerZones.Remove(player.userID.Get());
player.ChatMessage(Msg("Left", player.UserIDString, previous));
}
continue;
}
if (previous == zone.Name) continue;
playerZones[player.userID.Get()] = zone.Name;
if (previous != null)
{
player.ChatMessage(Msg("Left", player.UserIDString, previous));
}
player.ChatMessage(Msg("Entered", player.UserIDString, zone.Name));
}
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
if (player != null) playerZones.Remove(player.userID.Get());
}
private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo hitInfo)
{
if (entity == null || hitInfo == null) return null;
if (!(entity is BasePlayer) && !(entity is BuildingBlock)) return null;
if (TryGetZone(entity.transform.position) == null) return null;
hitInfo.damageTypes.ScaleAll(0f);
return false;
}
[ChatCommand("pvezone")]
private void CmdZone(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermAdmin))
{
player.ChatMessage(Msg("NoPermission", player.UserIDString));
return;
}
if (args == null || args.Length == 0)
{
player.ChatMessage(Msg("Usage", player.UserIDString));
return;
}
switch (args[0].ToLower())
{
case "add":
HandleAdd(player, args);
break;
case "remove":
HandleRemove(player, args);
break;
case "list":
HandleList(player);
break;
default:
player.ChatMessage(Msg("Usage", player.UserIDString));
break;
}
}
private void HandleAdd(BasePlayer player, string[] args)
{
if (args.Length < 3)
{
player.ChatMessage(Msg("Usage", player.UserIDString));
return;
}
float radius;
if (!float.TryParse(args[2], out radius) || radius <= 0f)
{
player.ChatMessage(Msg("InvalidRadius", player.UserIDString));
return;
}
var entry = new ZoneEntry
{
Name = args[1],
X = player.transform.position.x,
Y = player.transform.position.y,
Z = player.transform.position.z,
Radius = radius
};
config.Zones.Add(entry);
SaveConfig();
RebuildZones();
player.ChatMessage(Msg("Added", player.UserIDString, entry.Name, radius));
}
private void HandleRemove(BasePlayer player, string[] args)
{
if (args.Length < 2)
{
player.ChatMessage(Msg("Usage", player.UserIDString));
return;
}
var index = config.Zones.FindIndex(z => z != null && string.Equals(z.Name, args[1], StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
player.ChatMessage(Msg("NotFound", player.UserIDString, args[1]));
return;
}
var name = config.Zones[index].Name;
config.Zones.RemoveAt(index);
SaveConfig();
RebuildZones();
player.ChatMessage(Msg("Removed", player.UserIDString, name));
}
private void HandleList(BasePlayer player)
{
if (config.Zones == null || config.Zones.Count == 0)
{
player.ChatMessage(Msg("Empty", player.UserIDString));
return;
}
player.ChatMessage(Msg("ListHeader", player.UserIDString, config.Zones.Count));
foreach (var entry in config.Zones)
{
if (entry == null) continue;
player.ChatMessage(Msg("ListEntry", player.UserIDString, entry.Name,
string.Format("({0:0}, {1:0}, {2:0})", entry.X, entry.Y, entry.Z), entry.Radius));
}
}
}
}Hooks used: OnServerInitialized, OnPlayerDisconnected, OnEntityTakeDamage
Config file
Created automatically on first load at oxide/config/SafePveZones.json.
{
"Check Interval Seconds": 2,
"Zones": [
{
"Name": "safe",
"X": 0,
"Y": 0,
"Z": 0,
"Radius": 50
}
]
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/pvezone add <name> <radius> | Creates a zone centered on your current position with the given radius. | safepvezones.admin |
/pvezone remove <name> | Deletes a zone by name. | safepvezones.admin |
/pvezone list | Lists every configured zone with its center and radius. | safepvezones.admin |
| Permission | Grants |
|---|---|
safepvezones.admin | Grants use of the /pvezone chat commands to create, remove and list zones. |
How the code works
Define zones in the config
The
PluginConfigclass holdsCheck Interval Secondsand aZoneslist ofZoneEntryobjects. Each entry storesName,X,Y,ZandRadius. Keeping separate X/Y/Z values avoids serializing aVector3, which makes the JSON file easier to edit by hand.LoadConfigfalls back toLoadDefaultConfigand prints a warning if the file cannot be read, so a broken config never stops the server from starting.Build runtime zone data
In
OnServerInitializedthe plugin walksconfig.Zonesand fills aList<Zone>where each entry holds aVector3 centerandRadiusSqrcomputed once. Squared radius comparison avoids a square root on every distance check. The same helperTryGetZoneis reused by both the position ticker and the damage hook so entry detection and damage blocking always agree on what counts as inside.Track enter and leave with a timer
A
timer.Every(config.CheckIntervalSeconds, CheckPlayers)loop iteratesBasePlayer.activePlayerListand skips NPCs and sleeping players. ADictionary<ulong, string>records which zone each player was last seen in. When the current zone differs from the stored one, the plugin sends a chat message: the enter line when a zone is found, the leave line when the stored value is cleared. The dictionary is cleaned up inOnPlayerDisconnectedand wiped inUnload.Cancel damage inside a zone
OnEntityTakeDamage(BaseCombatEntity entity, HitInfo hitInfo)handles every combat entity. The plugin returnsnull(default behavior) unless the victim is aBasePlayeror aBuildingBlockthat sits inside a zone. When the position check passes it setshitInfo.damageTypes.ScaleAll(0f)and returnsfalse. Clearing the damage types meansOnEntityDeathand other plugins still see a real hit event, but nothing is applied to health.Admin commands and permission
/pvezone add <name> <radius>readsplayer.transform.position, pushes a newZoneEntryinto the config, saves it and rebuilds the runtime list, so the zone is live immediately.removeandlistwork on the same list. All three go throughpermission.UserHasPermission(player.UserIDString, PermAdmin)and reply throughlang. Names are compared case-insensitively so/pvezone remove PVEdeletespve.
Install it on your server
Save SafePveZones.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/SafePveZones.json(Carbon:carbon/configs/SafePveZones.json). After editing, reload it:oxide.reload SafePveZones c.reload SafePveZones # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default safepvezones.admin c.grant group default safepvezones.admin # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Protect base foundations only
Restrict the building protection to the zone owner and their teammates instead of any block in the sphere.
Generate this versionShow a countdown warning
Warn players a few seconds before leaving so they know they are about to become vulnerable.
Generate this versionPvE-only world setting
Disable player-versus-player damage everywhere and keep zones for building protection only.
Generate this version
FAQ
Does this stop explosions from damaging bases inside the zone?
Yes. Returning false from OnEntityTakeDamage for a BuildingBlock stops the damage from being applied, whatever the source. The rocket entity itself still explodes, and nearby entities outside the sphere are damaged normally.
Why is there a delay before the leave message?
Zone membership is checked on a timer, not on every frame. The default interval is 2 seconds, so a player who sprints across the boundary can be inside for up to one interval before the message appears. Lower Check Interval Seconds for sharper feedback at a small CPU cost.
How do I install it on Oxide or Carbon?
Drop SafePveZones.cs into oxide/plugins, wait for it to compile, then run oxide.grant user <name> safepvezones.admin from the server console. Carbon loads the same file from its plugins folder without changes.
Do sleeping players and NPCs get protected?
Sleeping players stay in the timer dictionary but the checker skips them, so they get no messages. They are still protected from damage while asleep inside the sphere, because the damage hook does not check connection state. NPCs are excluded entirely.
Can two zones overlap?
Yes. TryGetZone returns the first zone in config order that contains the point, so a player inside an overlap is announced once. Damage is blocked as long as any zone contains the victim, so overlapping zones never create gaps.
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 pve plugins
- Building damage protectionBlocks and deployables only take damage from their owner or players authorized on the covering tool cupboard.OnEntityTakeDamage
- Player corpse loot protectionLocks a dead player's corpse and dropped backpack to the owner and their current team for a configurable number of minutes.OnServerInitializedOnEntitySpawnedCanLootEntity
- Heli and Bradley loot lockTracks damage dealt to the Patrol Helicopter and Bradley APC per player and locks the crates they drop for a configurable time, allowing only theโฆOnServerInitializedOnServerSaveOnEntityTakeDamage
Last updated Oct 26, 2026.
Customize safe pve zones
Customize