Skip to content
RustPluginGenerator

How to make a Rust PvP zones plugin (spherical zones, Oxide/Carbon)

Blocks player-versus-player damage everywhere except inside named spherical zones defined in the config, using OnEntityTakeDamage to cancel damage between players outside those zones. Admins add, list and remove zones with console commands.

Most Rust servers want players to fight sometimes and farm safely the rest of the time. A simple way to get that is to allow player damage only inside defined arenas and cancel it everywhere else, so nobody loses gear to a random opponent while running to the next monument. The cleanest implementation point is OnEntityTakeDamage: when both the victim and the attacker are players, check whether the victim stands inside any configured zone and cancel the hit if not. NPC damage, fall damage and structure damage stay untouched.

The plugin below stores zones as a name, a world position and a radius. The /pvpzone add <name> console command drops a zone at your current position, /pvpzone list shows what exists and /pvpzone remove <name> deletes one. Everything is written back to the config file, so zones survive restarts without a database. Players in safe areas get a short chat reminder when their hit is blocked.

The plugin code

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

namespace Oxide.Plugins
{
    [Info("PvpZones", "RustPluginGenerator", "1.0.0")]
    [Description("Blocks player-versus-player damage outside configured spherical zones.")]
    public class PvpZones : RustPlugin
    {
        private const string PermAdmin = "pvpzones.admin";

        private PluginConfig config;
        private readonly Dictionary<ulong, float> lastNotice = new Dictionary<ulong, float>();

        private class ZoneEntry
        {
            [JsonProperty("Name")]
            public string Name { get; set; }

            [JsonProperty("X")]
            public float X { get; set; }

            [JsonProperty("Y")]
            public float Y { get; set; }

            [JsonProperty("Z")]
            public float Z { get; set; }

            [JsonProperty("Radius")]
            public float Radius { get; set; }

            [JsonIgnore]
            public Vector3 Center => new Vector3(X, Y, Z);
        }

        private class PluginConfig
        {
            [JsonProperty("Default radius")]
            public float DefaultRadius { get; set; } = 50f;

            [JsonProperty("Seconds between blocked-hit notices")]
            public float NoticeCooldown { get; set; } = 10f;

            [JsonProperty("Zones")]
            public List<ZoneEntry> Zones { get; set; } = 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();
                if (config.Zones == null) config.Zones = new List<ZoneEntry>();
            }
            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>
            {
                ["NoPvpZone"] = "PvP is disabled here. Fight inside a marked arena.",
                ["ZoneAdded"] = "Zone '{0}' created at {1} with radius {2}.",
                ["ZoneRemoved"] = "Zone '{0}' removed.",
                ["ZoneMissing"] = "No zone named '{0}'.",
                ["NoZones"] = "No PvP zones are configured.",
                ["ZoneList"] = "{0} - {1} r{2}",
                ["NeedName"] = "Usage: pvpzone add <name> | pvpzone remove <name> | pvpzone list",
                ["NoPermission"] = "You do not have permission to use this command.",
                ["PlayerOnly"] = "This command must be run in game from a player."
            }, 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(PermAdmin, this);
        }

        private object OnEntityTakeDamage(BasePlayer player, HitInfo hitInfo)
        {
            if (player == null || hitInfo == null) return null;
            var attacker = hitInfo.InitiatorPlayer;
            if (attacker == null || attacker == player) return null;
            if (attacker.IsNpc || player.IsNpc) return null;
            if (IsInsideAnyZone(player.transform.position)) return null;

            hitInfo.damageTypes.ScaleAll(0f);

            if (config.NoticeCooldown > 0f)
            {
                float now = UnityEngine.Time.realtimeSinceStartup;
                float last;
                if (!lastNotice.TryGetValue(player.userID, out last) || now - last >= config.NoticeCooldown)
                {
                    lastNotice[player.userID] = now;
                    player.ChatMessage(Msg("NoPvpZone", player.UserIDString));
                }
            }
            return true;
        }

        private bool IsInsideAnyZone(Vector3 position)
        {
            for (int i = 0; i < config.Zones.Count; i++)
            {
                var zone = config.Zones[i];
                if (zone == null || zone.Radius <= 0f) continue;
                if (Vector3.Distance(position, zone.Center) <= zone.Radius) return true;
            }
            return false;
        }

        [ConsoleCommand("pvpzone")]
        private void CmdPvpZone(ConsoleSystem.Arg arg)
        {
            var player = arg.Player();
            if (player != null && !permission.UserHasPermission(player.UserIDString, PermAdmin))
            {
                player.ChatMessage(Msg("NoPermission", player.UserIDString));
                return;
            }

            string sub = arg.GetString(0, string.Empty).ToLowerInvariant();
            if (sub == "add")
            {
                if (player == null)
                {
                    arg.ReplyWith(Msg("PlayerOnly"));
                    return;
                }
                string name = arg.GetString(1, string.Empty);
                if (string.IsNullOrEmpty(name))
                {
                    arg.ReplyWith(Msg("NeedName"));
                    return;
                }
                if (FindZone(name) != null)
                {
                    arg.ReplyWith(Msg("ZoneMissing", null, name));
                    return;
                }
                var pos = player.transform.position;
                var zone = new ZoneEntry { Name = name, X = pos.x, Y = pos.y, Z = pos.z, Radius = config.DefaultRadius };
                config.Zones.Add(zone);
                SaveConfig();
                arg.ReplyWith(Msg("ZoneAdded", null, zone.Name, pos.ToString("F0"), zone.Radius));
            }
            else if (sub == "remove")
            {
                string name = arg.GetString(1, string.Empty);
                var zone = FindZone(name);
                if (zone == null)
                {
                    arg.ReplyWith(Msg("ZoneMissing", null, name));
                    return;
                }
                config.Zones.Remove(zone);
                SaveConfig();
                arg.ReplyWith(Msg("ZoneRemoved", null, zone.Name));
            }
            else if (sub == "list")
            {
                if (config.Zones.Count == 0)
                {
                    arg.ReplyWith(Msg("NoZones"));
                    return;
                }
                for (int i = 0; i < config.Zones.Count; i++)
                {
                    var zone = config.Zones[i];
                    arg.ReplyWith(Msg("ZoneList", null, zone.Name, zone.Center.ToString("F0"), zone.Radius));
                }
            }
            else
            {
                arg.ReplyWith(Msg("NeedName"));
            }
        }

        private ZoneEntry FindZone(string name)
        {
            if (string.IsNullOrEmpty(name)) return null;
            for (int i = 0; i < config.Zones.Count; i++)
            {
                if (string.Equals(config.Zones[i].Name, name, StringComparison.OrdinalIgnoreCase)) return config.Zones[i];
            }
            return null;
        }

        private void Unload()
        {
            SaveConfig();
            lastNotice.Clear();
        }
    }
}

Hooks used: OnEntityTakeDamage

Config file

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

PvpZones.json
{
  "Default radius": 50,
  "Seconds between blocked-hit notices": 10,
  "Zones": [
    {
      "Name": "Example",
      "X": 0,
      "Y": 0,
      "Z": 0,
      "Radius": 50
    }
  ]
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
pvpzone add <name>Creates a spherical PvP zone at your current position using the configured default radius and saves it to the config.pvpzones.admin
pvpzone remove <name>Deletes the named zone from the config.pvpzones.admin
pvpzone listLists every zone with its position and radius.pvpzones.admin
Permissions
PermissionGrants
pvpzones.adminGrants access to the pvpzone console command (add, remove, list).

How the code works

  1. Config shape for zones

    The config class holds a List<ZoneEntry> where each entry has Name, X, Y, Z and Radius. Keeping plain floats instead of a Vector3 means the JSON stays readable and you can hand-edit coordinates without a serializer quirk. LoadConfig falls back to LoadDefaultConfig when the file cannot be parsed, so a broken edit never stops the plugin from starting. SaveConfig writes the list back after every add or remove.

  2. The damage hook

    object OnEntityTakeDamage(BasePlayer player, HitInfo hitInfo) fires for players only. Read the attacker from hitInfo.InitiatorPlayer and bail out when it is null, when the attacker is the victim, or when either side is an NPC. Then call IsInsideAnyZone(player.transform.position). If the answer is false, set hitInfo.damageTypes.ScaleAll(0f) and return true to mark the damage handled. Returning null lets normal damage through inside zones.

  3. Why the more specific overload is enough

    Oxide picks hook methods by checking that every argument matches the declared parameter types. Declaring BasePlayer means the plugin is only called when the victim is a player, so animals, deployables and building blocks keep vanilla behavior without extra type checks. If you later want to protect NPCs or consoles too, add another overload for BaseCombatEntity — but the current setup keeps the plugin focused and cheap on large servers.

  4. Point-in-sphere test

    IsInsideAnyZone loops over the cached zone list and compares Vector3.Distance(position, center) against Radius. Distance is squared internally by Unity, so a few dozen zones cost almost nothing per hit. Keep the list in a field rebuilt after load and after each command rather than reading the config object on every hit.

  5. Admin console command

    The single [ConsoleCommand("pvpzone")] handler reads arg.GetString(0, "") for the subcommand. add requires a name, pulls the caller from arg.Player() (null on RCON, so reply with a message), builds a new ZoneEntry from player.transform.position and the configured DefaultRadius, then saves. Because it is a console command it works from the game console or RCON; the permission check via permission.UserHasPermission keeps random players from using it when they are admins on another system.

  6. Player feedback

    When a hit is blocked the victim receives one localized message per cooldown window, tracked in a Dictionary<ulong, float> of last-notice timestamps and compared against UnityEngine.Time.realtimeSinceStartup. Without that throttle a firefight in a safe area would spam chat. All text lives in LoadDefaultMessages, so servers with a non-English player base can translate NoPvpZone and the admin replies in the language files.

Install it on your server

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

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

Variations

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

  • Show zones on the map

    Draw every configured zone as a circle in the in-game map so players can find the arenas.

    Generate this version
  • Cylindrical zones

    Ignore the Y axis so zones cover full columns of terrain and buildings.

    Generate this version
  • Gather multiplier inside zones

    Give a resource bonus inside PvP zones to reward risk.

    Generate this version

FAQ

Does the plugin block NPC or animal damage?

No. Only hits between two players are evaluated, because the hook overload declares BasePlayer and the plugin returns early when hitInfo.InitiatorPlayer is null. Scientists, bears and Bradley damage work exactly as in vanilla, so PvE content stays dangerous everywhere.

Where should I place the plugin file?

Drop PvpZones.cs into oxide/plugins and wait for Oxide or Carbon to compile it. The config is written to oxide/config/PvpZones.json on first load. Change a zone with the pvpzone console command from the in-game F1 console or through RCON.

How do I find coordinates for a zone?

Stand where you want the center and run pvpzone add <name> — it records your current position. Radius comes from Default Radius in the config; edit the JSON afterwards if you need a larger or smaller arena, or add multiple entries by hand.

What happens to vehicles and raid damage inside safe areas?

The plugin only cancels damage where the victim is a player, so rockets fired at a base outside a zone still destroy it. A player standing in the safe area while shooting out is protected from return fire; if you need the opposite behavior, remove the zone check from the attacker side or add a distance check.

Does this conflict with other damage plugins?

Multiple plugins can subscribe to OnEntityTakeDamage; Oxide runs them in load order and the first non-null return cancels the rest. If a raid or god-mode plugin runs before PvpZones it can override the cancellation. Load order can be adjusted by renaming files or using a permission/plugin hook to coordinate.

Customize it for your server

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

Prompt: Create an Oxide Rust plugin named PvpZones that blocks player-versus-player damage outside spherical zones stored in the config, with a pvpzone console command to add, list and remove zones and a default radius setting.
Customize for my server

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

Last updated Oct 26, 2026.

Customize pvp zones

Customize