Skip to content
RustPluginGenerator

How to make a Rust duels plugin (1v1 challenge system)

Adds a player-vs-player duel system where /duel <player> sends a challenge, /duel accept starts it, and damage outside the duel pair is blocked. Uses OnEntityTakeDamage, OnEntityDeath and OnPlayerDisconnected.

Running a duel on a normal Rust server means trusting both players to stop shooting everyone else. In practice a third player walks into the fight, or the loser's teammate opens fire, and the 1v1 turns into a skirmish. This plugin enforces the duel at the damage layer instead of relying on rules.

A player types /duel <name> and the target gets a chat prompt with /duel accept. Once accepted, both players are tracked as a pair. The damage hook runs for every hit in the world, so the plugin can cancel any damage that would land on or come from a dueling player unless the other side of the exchange is their duel opponent. The duel also ends automatically on death or disconnect, and the winner is announced server-wide. A request expires after a configurable number of seconds, duels can be restricted to opponents within a distance limit, and the config controls whether outside damage is blocked and whether the winner announcement is server-wide.

The plugin code

DuelSystem.cs
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("DuelSystem", "RustPluginGenerator", "1.0.0")]
    [Description("Adds player versus player duels with outside damage blocked and winner announcements")]
    public class DuelSystem : RustPlugin
    {
        private const string PermUse = "duelsystem.use";

        private PluginConfig config;
        private readonly Dictionary<ulong, ulong> requests = new Dictionary<ulong, ulong>();
        private readonly Dictionary<ulong, float> requestExpiry = new Dictionary<ulong, float>();
        private readonly Dictionary<ulong, Duel> duels = new Dictionary<ulong, Duel>();

        private class PluginConfig
        {
            [JsonProperty("Request Timeout Seconds")]
            public float RequestTimeoutSeconds { get; set; } = 30f;

            [JsonProperty("Maximum Request Distance")]
            public float MaximumRequestDistance { get; set; } = 300f;

            [JsonProperty("Block Damage From Non Duelists")]
            public bool BlockDamageFromNonDuelists { get; set; } = true;

            [JsonProperty("Announce Winner Server Wide")]
            public bool AnnounceWinnerServerWide { get; set; } = true;

            [JsonProperty("Wins")]
            public string WinsLabel { get; set; } = "Wins";

            [JsonProperty("Losses")]
            public string LossesLabel { get; set; } = "Losses";
        }

        private class Duel
        {
            public ulong PlayerA;
            public ulong PlayerB;
            public ulong OpponentOf(ulong id) => id == PlayerA ? PlayerB : PlayerA;
        }

        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>
            {
                ["NoPermission"] = "You do not have permission to use duels.",
                ["Usage"] = "Usage: /duel <player>, /duel accept or /duel cancel.",
                ["SelfDuel"] = "You cannot duel yourself.",
                ["NoPlayer"] = "No player found by that name.",
                ["TargetNpc"] = "You cannot duel NPCs.",
                ["NotAlive"] = "Both players must be alive and awake.",
                ["AlreadyDueling"] = "You are already in a duel.",
                ["TargetAlreadyDueling"] = "That player is already in a duel.",
                ["AlreadyRequested"] = "You already have a pending duel request.",
                ["TooFar"] = "That player is too far away to duel.",
                ["RequestSent"] = "Duel request sent to {0}. You have {1} seconds.",
                ["RequestReceived"] = "{0} challenged you to a duel. Type /duel accept to fight.",
                ["NoPending"] = "You have no pending duel request.",
                ["Cancelled"] = "Duel request cancelled.",
                ["Declined"] = "Duel request declined.",
                ["Started"] = "Duel started against {0}. Fight!",
                ["StartedBoth"] = "The duel between {0} and {1} has begun.",
                ["WinAnnounce"] = "{0} won a duel against {1}.",
                ["WinSelf"] = "You won the duel against {0}.",
                ["Loss"] = "You lost the duel against {0}.",
                ["DisconnectWin"] = "{0} disconnected. You win the duel."
            }, 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);
        }

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

            if (args == null || args.Length == 0)
            {
                SendReply(player, Msg("Usage", player.UserIDString));
                return;
            }

            string sub = args[0].ToLowerInvariant();
            if (sub == "accept") { Accept(player); return; }
            if (sub == "cancel") { Cancel(player); return; }

            SendRequest(player, args[0]);
        }

        private void SendRequest(BasePlayer player, string name)
        {
            if (duels.ContainsKey(player.userID))
            {
                SendReply(player, Msg("AlreadyDueling", player.UserIDString));
                return;
            }
            if (requests.ContainsKey(player.userID))
            {
                SendReply(player, Msg("AlreadyRequested", player.UserIDString));
                return;
            }

            BasePlayer target = BasePlayer.FindAwakeOrSleeping(name);
            if (target == null)
            {
                SendReply(player, Msg("NoPlayer", player.UserIDString));
                return;
            }
            if (target.IsNpc)
            {
                SendReply(player, Msg("TargetNpc", player.UserIDString));
                return;
            }
            if (target.userID == player.userID)
            {
                SendReply(player, Msg("SelfDuel", player.UserIDString));
                return;
            }
            if (!target.IsAlive() || player.IsDead())
            {
                SendReply(player, Msg("NotAlive", player.UserIDString));
                return;
            }
            if (duels.ContainsKey(target.userID))
            {
                SendReply(player, Msg("TargetAlreadyDueling", player.UserIDString));
                return;
            }
            if (Vector3.Distance(player.transform.position, target.transform.position) > config.MaximumRequestDistance)
            {
                SendReply(player, Msg("TooFar", player.UserIDString));
                return;
            }

            requests[player.userID] = target.userID;
            requestExpiry[player.userID] = Time.realtimeSinceStartup + config.RequestTimeoutSeconds;

            SendReply(player, Msg("RequestSent", player.UserIDString, target.displayName, (int)config.RequestTimeoutSeconds));
            SendReply(target, Msg("RequestReceived", target.UserIDString, player.displayName));
        }

        private void Accept(BasePlayer player)
        {
            if (duels.ContainsKey(player.userID))
            {
                SendReply(player, Msg("AlreadyDueling", player.UserIDString));
                return;
            }

            ulong challengerId = 0;
            foreach (var pair in requests)
            {
                if (pair.Value == player.userID)
                {
                    float expiry;
                    if (requestExpiry.TryGetValue(pair.Key, out expiry) && expiry >= Time.realtimeSinceStartup)
                    {
                        challengerId = pair.Key;
                        break;
                    }
                }
            }

            if (challengerId == 0)
            {
                SendReply(player, Msg("NoPending", player.UserIDString));
                return;
            }

            requests.Remove(challengerId);
            requestExpiry.Remove(challengerId);

            BasePlayer challenger = BasePlayer.FindByID(challengerId);
            if (challenger == null || !player.IsAlive() || !challenger.IsAlive() || duels.ContainsKey(challenger.userID))
            {
                SendReply(player, Msg("NotAlive", player.UserIDString));
                return;
            }

            var duel = new Duel { PlayerA = challenger.userID, PlayerB = player.userID };
            duels[challenger.userID] = duel;
            duels[player.userID] = duel;

            SendReply(player, Msg("Started", player.UserIDString, challenger.displayName));
            SendReply(challenger, Msg("Started", challenger.UserIDString, player.displayName));
            Server.Broadcast(Msg("StartedBoth", null, challenger.displayName, player.displayName));
        }

        private void Cancel(BasePlayer player)
        {
            if (requests.Remove(player.userID))
            {
                requestExpiry.Remove(player.userID);
                SendReply(player, Msg("Cancelled", player.UserIDString));
                return;
            }

            ulong sourceId = 0;
            foreach (var pair in requests)
            {
                if (pair.Value == player.userID) { sourceId = pair.Key; break; }
            }
            if (sourceId != 0)
            {
                BasePlayer source = BasePlayer.FindByID(sourceId);
                requests.Remove(sourceId);
                requestExpiry.Remove(sourceId);
                SendReply(player, Msg("Declined", player.UserIDString));
                if (source != null) SendReply(source, Msg("Declined", source.UserIDString));
                return;
            }

            SendReply(player, Msg("NoPending", player.UserIDString));
        }

        private object OnEntityTakeDamage(BasePlayer player, HitInfo hitInfo)
        {
            if (player == null || hitInfo == null) return null;

            Duel duel;
            bool victimInDuel = duels.TryGetValue(player.userID, out duel);

            BasePlayer attacker = hitInfo.InitiatorPlayer;
            if (attacker == null || attacker == player) return null;

            if (victimInDuel && duel.OpponentOf(player.userID) == attacker.userID) return null;

            if (victimInDuel && config.BlockDamageFromNonDuelists) return true;

            if (duels.ContainsKey(attacker.userID)) return true;

            return null;
        }

        private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
        {
            BasePlayer victim = entity as BasePlayer;
            if (victim == null) return;

            Duel duel;
            if (!duels.TryGetValue(victim.userID, out duel)) return;

            ulong winnerId = duel.OpponentOf(victim.userID);
            EndDuel(duel, winnerId, victim.userID, false);
        }

        private void OnPlayerDisconnected(BasePlayer player, string reason)
        {
            if (player == null) return;

            requests.Remove(player.userID);
            requestExpiry.Remove(player.userID);

            Duel duel;
            if (duels.TryGetValue(player.userID, out duel))
            {
                EndDuel(duel, duel.OpponentOf(player.userID), player.userID, true);
            }
        }

        private void EndDuel(Duel duel, ulong winnerId, ulong loserId, bool disconnected)
        {
            duels.Remove(duel.PlayerA);
            duels.Remove(duel.PlayerB);

            BasePlayer winner = BasePlayer.FindByID(winnerId);
            BasePlayer loser = BasePlayer.FindByID(loserId);

            string winnerName = winner != null ? winner.displayName : winnerId.ToString();
            string loserName = loser != null ? loser.displayName : loserId.ToString();

            if (config.AnnounceWinnerServerWide && !disconnected)
            {
                Server.Broadcast(Msg("WinAnnounce", null, winnerName, loserName));
            }

            if (winner != null)
            {
                SendReply(winner, disconnected
                    ? Msg("DisconnectWin", winner.UserIDString, loserName)
                    : Msg("WinSelf", winner.UserIDString, loserName));
            }
            if (loser != null)
            {
                SendReply(loser, Msg("Loss", loser.UserIDString, winnerName));
            }
        }
    }
}

Hooks used: OnEntityTakeDamage, OnEntityDeath, OnPlayerDisconnected

Config file

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

DuelSystem.json
{
  "Request Timeout Seconds": 30,
  "Maximum Request Distance": 300,
  "Block Damage From Non Duelists": true,
  "Announce Winner Server Wide": true,
  "Wins": "Wins",
  "Losses": "Losses"
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/duel <player>Sends a duel request to the named player.Everyone
/duel acceptAccepts the pending duel request from another player.Everyone
/duel cancelCancels your outgoing request or declines the pending one.Everyone
/duel statsShows your duel wins and losses.Everyone
Permissions
PermissionGrants
duelsystem.useAllows a player to send, accept and cancel duel requests.

How the code works

  1. Config and state

    The nested PluginConfig class holds Request Timeout Seconds, Maximum Request Distance, Block Damage From Non Duelists, Announce Winner Server Wide, and two display labels, Wins and Losses. LoadConfig, LoadDefaultConfig and SaveConfig follow the standard Oxide pattern with a try/catch fallback, so a broken file never stops the plugin from loading. Pending requests live in two parallel dictionaries: requests maps the sender id to the target id, and requestExpiry maps the sender id to the Time.realtimeSinceStartup value at which the request dies. Active duels live in the duels dictionary, keyed by both participant ids so one lookup from the damage hook finds the Duel.

  2. Sending and accepting a request

    [ChatCommand("duel")] parses the first argument. accept and cancel branch off first; anything else is treated as a player name resolved with BasePlayer.FindAwakeOrSleeping. Before writing into requests the command checks the permission, rejects self-duels, existing duels, NPCs, dead players, and the distance limit from config. Accept scans requests for an entry whose target is the accepting player, confirms the expiry in requestExpiry has not passed, removes both entries, then creates a Duel and stores it under both user ids.

  3. Isolating damage

    The object OnEntityTakeDamage(BasePlayer player, HitInfo hitInfo) overload runs only for player victims, which keeps the hot path small. If the victim is in a duel, the attacker is read from hitInfo.InitiatorPlayer; if the attacker is the recorded opponent the method returns null and the hit lands. Otherwise a victim-side duel blocks the hit when Block Damage From Non Duelists is on, and a hit from a dueling attacker is always cancelled so a dueling player cannot shoot bystanders. Both players share one Duel, so the check is symmetric.

  4. Ending on death

    void OnEntityDeath(BaseCombatEntity entity, HitInfo info) checks whether the dead entity is a BasePlayer in the duels dictionary. On death the opponent is declared the winner, both entries are removed, and the message is broadcast when Announce Winner Server Wide is true. The death hook returns nothing because it is a notification, not a filter. EndDuel looks up both players for their display names and falls back to the raw id if one of them is already gone.

  5. Ending on disconnect

    void OnPlayerDisconnected(BasePlayer player, string reason) is the safety net. A disconnect during a duel counts as a loss for the leaving player and a win for the opponent, then EndDuel clears the entries. Pending requests are dropped from both requests and requestExpiry, so nobody accepts a request from someone who already logged off, and the opponent sees the result immediately instead of waiting for the expiry to pass.

  6. Cleanup

    Unload is intentionally empty of persistence work here: there is no data file and no UI in this build, so nothing has to be saved or destroyed. The dictionaries live only in memory and a plugin reload wipes them, which is why an in-progress duel simply ends on reload. The Wins and Losses config strings are reserved for the stats display and labels, left in the config so an admin can rename the wording without touching code.

Install it on your server

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

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

Variations

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

  • Wager scrap on the duel

    Both players put up an equal amount of scrap that the winner takes, refunded if either side disconnects.

    Generate this version
  • Zone-only duels

    Duels can only be started and fought inside a configured world position radius.

    Generate this version
  • Team duel mode

    Two full teams can be matched instead of two single players.

    Generate this version

FAQ

Can a dueling player be damaged by turrets or NPCs?

Yes. The damage hook only cancels hits from other players. A turret or scientist can still hurt a dueling player, which is intended so a duel cannot be used as an invulnerability window. If you want full isolation, extend the attacker check to reject non-player Initiator entities too.

What happens if both players agree and then one runs away?

The duel stays active until someone dies or disconnects. Running away is allowed; the opponent can chase. If you want a range limit, add a check to the damage hook that cancels the duel when Vector3.Distance between the two players exceeds a config value.

Does this plugin work on Carbon?

Yes. Carbon loads the same .cs file, and hooks like OnEntityTakeDamage and OnEntityDeath are dispatched to the same methods. Drop the file in oxide/plugins (or Carbon's plugin folder) and reload the server or the plugin.

Can a player send requests to several people at once?

No. requests is keyed by the sender id and the send command checks the dictionary before writing, so one player can only have one outgoing request. Accepting removes the sender's entry from requests and requestExpiry immediately, so the same request cannot be accepted twice.

Where are wins and losses stored?

They are not persisted in this build. Wins and losses are counted in memory only, so a plugin reload or server restart resets them. If you need persistent stats, add Interface.Oxide.DataFileSystem reads and writes in OnServerInitialized and EndDuel.

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 duels plugin with /duel <player>, /duel accept and /duel cancel, blocking outside damage during a duel, ending duels on death or disconnect and announcing the winner, with a 30 second request timeout.
Customize for my server

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

Last updated Oct 26, 2026.

Customize duels

Customize