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
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.
{
"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
| Command | What it does | Permission |
|---|---|---|
/duel <player> | Sends a duel request to the named player. | Everyone |
/duel accept | Accepts the pending duel request from another player. | Everyone |
/duel cancel | Cancels your outgoing request or declines the pending one. | Everyone |
/duel stats | Shows your duel wins and losses. | Everyone |
| Permission | Grants |
|---|---|
duelsystem.use | Allows a player to send, accept and cancel duel requests. |
How the code works
Config and state
The nested
PluginConfigclass holdsRequest Timeout Seconds,Maximum Request Distance,Block Damage From Non Duelists,Announce Winner Server Wide, and two display labels,WinsandLosses.LoadConfig,LoadDefaultConfigandSaveConfigfollow 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:requestsmaps the sender id to the target id, andrequestExpirymaps the sender id to theTime.realtimeSinceStartupvalue at which the request dies. Active duels live in theduelsdictionary, keyed by both participant ids so one lookup from the damage hook finds theDuel.Sending and accepting a request
[ChatCommand("duel")]parses the first argument.acceptandcancelbranch off first; anything else is treated as a player name resolved withBasePlayer.FindAwakeOrSleeping. Before writing intorequeststhe command checks the permission, rejects self-duels, existing duels, NPCs, dead players, and the distance limit from config.Acceptscansrequestsfor an entry whose target is the accepting player, confirms the expiry inrequestExpiryhas not passed, removes both entries, then creates aDueland stores it under both user ids.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 fromhitInfo.InitiatorPlayer; if the attacker is the recorded opponent the method returnsnulland the hit lands. Otherwise a victim-side duel blocks the hit whenBlock Damage From Non Duelistsis on, and a hit from a dueling attacker is always cancelled so a dueling player cannot shoot bystanders. Both players share oneDuel, so the check is symmetric.Ending on death
void OnEntityDeath(BaseCombatEntity entity, HitInfo info)checks whether the dead entity is aBasePlayerin theduelsdictionary. On death the opponent is declared the winner, both entries are removed, and the message is broadcast whenAnnounce Winner Server Wideis true. The death hook returns nothing because it is a notification, not a filter.EndDuellooks up both players for their display names and falls back to the raw id if one of them is already gone.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, thenEndDuelclears the entries. Pending requests are dropped from bothrequestsandrequestExpiry, 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.Cleanup
Unloadis 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. TheWinsandLossesconfig 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
Save DuelSystem.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/DuelSystem.json(Carbon:carbon/configs/DuelSystem.json). After editing, reload it:oxide.reload DuelSystem c.reload DuelSystem # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip: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 versionZone-only duels
Duels can only be started and fought inside a configured world position radius.
Generate this versionTeam 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.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More pvp plugins
- Combat blockTracks combat with the OnEntityTakeDamage hook and blocks a configurable list of chat commands while a player is in combat, using OnPlayerCommand toโฆOnEntityTakeDamageOnPlayerCommand
- Friendly fire protectionBlocks damage between members of the same Rust team using OnEntityTakeDamage, with a permission whitelist and a /ff toggle for team leaders thatโฆOnEntityTakeDamageOnPlayerDisconnected
- Headshot announcementsAnnounces headshot kills in chat with killer, victim, weapon and distance, and grants a configurable bonus item when the shot traveled at least aโฆOnServerInitializedOnEntityDeath
Last updated Oct 26, 2026.
Customize duels
Customize