Skip to content
RustPluginGenerator

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

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

SafePveZones.json
{
  "Check Interval Seconds": 2,
  "Zones": [
    {
      "Name": "safe",
      "X": 0,
      "Y": 0,
      "Z": 0,
      "Radius": 50
    }
  ]
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/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 listLists every configured zone with its center and radius.safepvezones.admin
Permissions
PermissionGrants
safepvezones.adminGrants use of the /pvezone chat commands to create, remove and list zones.

How the code works

  1. Define zones in the config

    The PluginConfig class holds Check Interval Seconds and a Zones list of ZoneEntry objects. Each entry stores Name, X, Y, Z and Radius. Keeping separate X/Y/Z values avoids serializing a Vector3, which makes the JSON file easier to edit by hand. LoadConfig falls back to LoadDefaultConfig and prints a warning if the file cannot be read, so a broken config never stops the server from starting.

  2. Build runtime zone data

    In OnServerInitialized the plugin walks config.Zones and fills a List<Zone> where each entry holds a Vector3 center and RadiusSqr computed once. Squared radius comparison avoids a square root on every distance check. The same helper TryGetZone is reused by both the position ticker and the damage hook so entry detection and damage blocking always agree on what counts as inside.

  3. Track enter and leave with a timer

    A timer.Every(config.CheckIntervalSeconds, CheckPlayers) loop iterates BasePlayer.activePlayerList and skips NPCs and sleeping players. A Dictionary<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 in OnPlayerDisconnected and wiped in Unload.

  4. Cancel damage inside a zone

    OnEntityTakeDamage(BaseCombatEntity entity, HitInfo hitInfo) handles every combat entity. The plugin returns null (default behavior) unless the victim is a BasePlayer or a BuildingBlock that sits inside a zone. When the position check passes it sets hitInfo.damageTypes.ScaleAll(0f) and returns false. Clearing the damage types means OnEntityDeath and other plugins still see a real hit event, but nothing is applied to health.

  5. Admin commands and permission

    /pvezone add <name> <radius> reads player.transform.position, pushes a new ZoneEntry into the config, saves it and rebuilds the runtime list, so the zone is live immediately. remove and list work on the same list. All three go through permission.UserHasPermission(player.UserIDString, PermAdmin) and reply through lang. Names are compared case-insensitively so /pvezone remove PVE deletes pve.

Install it on your server

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

    Give it to every player, or swap default for a group such as vip: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 version
  • Show a countdown warning

    Warn players a few seconds before leaving so they know they are about to become vulnerable.

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

Prompt: Create a Rust Oxide plugin named SafePveZones with spherical PvE zones stored in the config, a 2 second entry/exit chat timer, damage blocking via OnEntityTakeDamage and /pvezone add, remove and list for admins.
Customize for my server

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

Last updated Oct 26, 2026.

Customize safe pve zones

Customize