How to make a Rust team switch cooldown plugin (leave/kick lockout)
Blocks a player from joining a team for N hours after leaving or being kicked, using OnTeamLeave, OnTeamKick and OnTeamAcceptInvite with per-player UTC timestamps saved in oxide/data.
Team hopping is the usual way groups dodge a fight loss: leave the team, join another, share the loot, repeat. Rust has no built-in lockout, so the fix belongs in a plugin that stamps the moment a player leaves a team or is removed from one, then refuses the next invite until the configured window has passed.
The cooldown must survive restarts and must not punish players who are removed because their team loses its last member. This plugin hooks OnTeamLeave and OnTeamKick, records DateTime.UtcNow.Ticks plus the old team id in oxide/data/TeamSwitchCooldown.json, and checks that record inside OnTeamAcceptInvite. Admins can inspect and clear records with console commands, and the remaining time is shown to the player in chat when an invite is blocked.
The plugin code
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Team Switch Cooldown", "RustPluginGenerator", "1.0.0")]
[Description("Applies a cooldown after a player leaves or is kicked from a team.")]
public class TeamSwitchCooldown : RustPlugin
{
private const string PermAdmin = "teamswitchcooldown.admin";
private const string PermBypass = "teamswitchcooldown.bypass";
private const string DataFile = "TeamSwitchCooldown";
private PluginConfig config;
private Dictionary<string, PlayerRecord> records = new Dictionary<string, PlayerRecord>();
public class PlayerRecord
{
public long LeftAtTicks;
public ulong OldTeamId;
}
private class PluginConfig
{
[JsonProperty("Cooldown hours")]
public double CooldownHours = 6.0;
[JsonProperty("Ignore admins")]
public bool IgnoreAdmins = true;
[JsonProperty("Ignore last member leaving")]
public bool IgnoreLastMemberLeave = true;
[JsonProperty("Record expiry days")]
public int RecordExpiryDays = 30;
}
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>
{
["Blocked"] = "You cannot join a team for another {0}. You recently left team {1}.",
["AdminCheck"] = "Player {0}: old team {1}, remaining {2}.",
["AdminCheckNone"] = "No active cooldown for player {0}.",
["AdminCleared"] = "Cleared cooldown for player {0}.",
["AdminNotFound"] = "No record found for player {0}.",
["AdminUsage"] = "Usage: teamcooldown.check <steamid> | teamcooldown.clear <steamid>"
}, 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);
permission.RegisterPermission(PermBypass, this);
}
private void OnServerInitialized()
{
LoadData();
}
private void Unload()
{
SaveData();
}
private void OnServerSave()
{
SaveData();
}
private void LoadData()
{
var stored = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, PlayerRecord>>(DataFile);
records = stored ?? new Dictionary<string, PlayerRecord>();
if (config.RecordExpiryDays <= 0) return;
var cutoff = DateTime.UtcNow.AddDays(-config.RecordExpiryDays);
var stale = new List<string>();
foreach (var pair in records)
{
if (new DateTime(pair.Value.LeftAtTicks, DateTimeKind.Utc) < cutoff)
stale.Add(pair.Key);
}
foreach (var id in stale) records.Remove(id);
}
private void SaveData()
{
Interface.Oxide.DataFileSystem.WriteObject(DataFile, records);
}
private void ApplyCooldown(ulong userId, ulong teamId)
{
if (config.CooldownHours <= 0) return;
var id = userId.ToString();
if (permission.UserHasPermission(id, PermBypass)) return;
var player = BasePlayer.FindByID(userId);
if (player != null && config.IgnoreAdmins && player.IsAdmin) return;
records[id] = new PlayerRecord
{
LeftAtTicks = DateTime.UtcNow.Ticks,
OldTeamId = teamId
};
SaveData();
if (player != null)
player.ChatMessage(string.Format(CultureInfo.InvariantCulture,
"Team changed. You cannot join a new team for {0}.", FormatTime(TimeSpan.FromHours(config.CooldownHours))));
}
private object OnTeamLeave(RelationshipManager.PlayerTeam playerTeam, BasePlayer player)
{
if (playerTeam == null || player == null) return null;
if (!player.IsConnected) return null;
if (config.IgnoreLastMemberLeave && playerTeam.members != null && playerTeam.members.Count <= 1)
return null;
ApplyCooldown(player.userID, playerTeam.teamID);
return null;
}
private object OnTeamKick(RelationshipManager.PlayerTeam playerTeam, BasePlayer player, ulong uLong)
{
if (playerTeam == null) return null;
if (uLong == playerTeam.teamLeader && player != null) return null;
if (config.IgnoreLastMemberLeave && playerTeam.members != null && playerTeam.members.Count <= 1)
return null;
ApplyCooldown(uLong, playerTeam.teamID);
return null;
}
private object OnTeamAcceptInvite(RelationshipManager.PlayerTeam playerTeam, BasePlayer player)
{
if (playerTeam == null || player == null) return null;
var id = player.UserIDString;
if (permission.UserHasPermission(id, PermBypass)) return null;
if (config.IgnoreAdmins && player.IsAdmin) return null;
PlayerRecord record;
if (!records.TryGetValue(id, out record)) return null;
var leftAt = new DateTime(record.LeftAtTicks, DateTimeKind.Utc);
var endsAt = leftAt.AddHours(config.CooldownHours);
var remaining = endsAt - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero) return null;
player.ChatMessage(string.Format(CultureInfo.InvariantCulture,
Msg("Blocked", id, FormatTime(remaining), record.OldTeamId)));
return false;
}
private static string FormatTime(TimeSpan span)
{
if (span.TotalMinutes < 1) return "less than a minute";
if (span.TotalHours < 1) return string.Format(CultureInfo.InvariantCulture, "{0} min", (int)span.TotalMinutes);
return string.Format(CultureInfo.InvariantCulture, "{0} h {1} min", (int)span.TotalHours, span.Minutes);
}
[ConsoleCommand("teamcooldown.check")]
private void CcmdCheck(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin && (arg.Player() == null || !permission.UserHasPermission(arg.Player().UserIDString, PermAdmin)))
{
arg.ReplyWith("No permission.");
return;
}
var id = arg.GetString(0, "");
if (string.IsNullOrEmpty(id))
{
arg.ReplyWith(Msg("AdminUsage"));
return;
}
PlayerRecord record;
if (!records.TryGetValue(id, out record))
{
arg.ReplyWith(string.Format(Msg("AdminCheckNone"), id));
return;
}
var endsAt = new DateTime(record.LeftAtTicks, DateTimeKind.Utc).AddHours(config.CooldownHours);
var remaining = endsAt - DateTime.UtcNow;
var text = remaining > TimeSpan.Zero ? FormatTime(remaining) : "none";
arg.ReplyWith(string.Format(Msg("AdminCheck"), id, record.OldTeamId, text));
}
[ConsoleCommand("teamcooldown.clear")]
private void CcmdClear(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin && (arg.Player() == null || !permission.UserHasPermission(arg.Player().UserIDString, PermAdmin)))
{
arg.ReplyWith("No permission.");
return;
}
var id = arg.GetString(0, "");
if (string.IsNullOrEmpty(id))
{
arg.ReplyWith(Msg("AdminUsage"));
return;
}
if (!records.Remove(id))
{
arg.ReplyWith(string.Format(Msg("AdminNotFound"), id));
return;
}
SaveData();
arg.ReplyWith(string.Format(Msg("AdminCleared"), id));
}
}
}Hooks used: OnServerInitialized, OnServerSave, OnTeamLeave, OnTeamKick, OnTeamAcceptInvite
Config file
Created automatically on first load at oxide/config/TeamSwitchCooldown.json.
{
"Cooldown hours": 6,
"Ignore admins": true,
"Ignore last member leaving": true,
"Record expiry days": 30
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
teamcooldown.check <steamid> | Prints the stored record for a player: old team id and remaining cooldown, or a message when no record exists. | teamswitchcooldown.admin |
teamcooldown.clear <steamid> | Deletes the cooldown record for a player so they can join a team immediately. | teamswitchcooldown.admin |
| Permission | Grants |
|---|---|
teamswitchcooldown.admin | Allows using the teamcooldown.check and teamcooldown.clear console commands |
teamswitchcooldown.bypass | Player never receives a cooldown on leave or kick and is not blocked on invite |
How the code works
Record the leave timestamp
object OnTeamLeave(RelationshipManager.PlayerTeam playerTeam, BasePlayer player)fires when a player leaves or is removed from a team.ApplyCooldownwritesDateTime.UtcNow.TicksandplayerTeam.teamIDintorecords[player.userID.ToString()]and saves the data file. Returning null keeps default behavior. The method exits early whenCooldownHoursis 0, when the player has the bypass permission, or whenIgnoreAdminsis true andplayer.IsAdminis set.Handle kicks separately
object OnTeamKick(RelationshipManager.PlayerTeam playerTeam, BasePlayer player, ulong uLong)is called when a team leader kicks a member. The kicked player is the third argument, so the record is written foruLong, not forplayer. The hook skips the leader kicking themselves and skips teams whosemembers.Countis 1 or less whenIgnoreLastMemberLeaveis true, matching the leave hook.Enforce the lockout on invites
object OnTeamAcceptInvite(RelationshipManager.PlayerTeam playerTeam, BasePlayer player)runs before the player joins. The plugin readsrecords[player.UserIDString], computesendsAtas the stored UTC time plusCooldownHours, and if the difference is still positive sends theBlockedlanguage key throughplayer.ChatMessageand returnsfalse, which cancels the accept. Expired or missing records return null, so the join goes through.Persist per-player records
Records are a
Dictionary<string, PlayerRecord>keyed byUserIDString, each holdingLeftAtTicksandOldTeamId.LoadDataruns inOnServerInitializedand readsoxide/data/TeamSwitchCooldown.jsonthroughInterface.Oxide.DataFileSystem, then removes entries older thanRecordExpiryDays.SaveDatawrites the file after every change, and again fromOnServerSaveandUnload.Skip non-punishing cases
OnTeamLeavealso fires when a team is disbanded or the last member walks out, so the hook checksplayerTeam.members.CountwhenIgnoreLastMemberLeaveis true and returns without a record if the team is down to one member. Admins are exempt whenIgnoreAdminsis true, andteamswitchcooldown.bypassis checked withpermission.UserHasPermissionon both the leave and invite hooks.Admin tooling and cleanup
[ConsoleCommand("teamcooldown.check")]and[ConsoleCommand("teamcooldown.clear")]take the Steam ID fromarg.GetString(0, ""), so they work from the server console and RCON. Both checkarg.IsAdminor theteamswitchcooldown.adminpermission onarg.Player().Unloadsaves the data file; there are no timers or UI elements to tear down because the plugin only reacts to hooks.
Install it on your server
Save TeamSwitchCooldown.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/TeamSwitchCooldown.json(Carbon:carbon/configs/TeamSwitchCooldown.json). After editing, reload it:oxide.reload TeamSwitchCooldown c.reload TeamSwitchCooldown # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default teamswitchcooldown.admin c.grant group default teamswitchcooldown.admin # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Warn before leaving
Sends a chat warning to players who leave a team, telling them the cooldown that now applies.
Generate this versionScrap penalty alternative
Pays a configurable scrap cost to the leaving player instead of a timed lockout.
Generate this versionAdmin bypass list
Adds an explicit Steam ID list that is never given a cooldown.
Generate this version
FAQ
Does the cooldown also block creating a new team?
No. The plugin only listens to OnTeamAcceptInvite. A player with an active record can still create a team, because that path does not call this hook. Subscribe to OnTeamCreate and run the same records lookup there if you want to close that gap.
What happens if a team is disbanded while three members are online?
Each member triggers OnTeamLeave. With the default IgnoreLastMemberLeave = true, the hook skips the record once playerTeam.members.Count is 1 or less, so the earlier leavers still get a record while the final member does not. Set the key to false if a disband should lock everyone out.
Where is the data stored and does it survive wipes?
Records are written to oxide/data/TeamSwitchCooldown.json through Interface.Oxide.DataFileSystem. The file survives a normal map wipe; it is deleted only if you remove oxide/data. Entries older than RecordExpiryDays are pruned on load.
How do I install it on Oxide or Carbon?
Place TeamSwitchCooldown.cs in oxide/plugins, reload the plugin, edit oxide/config/TeamSwitchCooldown.json and run oxide.reload TeamSwitchCooldown. Carbon loads the same file from carbon/plugins.
Can a player see how long is left?
Yes. When an invite is refused, the plugin sends the Blocked message with the remaining time formatted by FormatTime and the old team id. Admins get the same numbers from teamcooldown.check <steamid>.
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 teams & clans plugins
- Simple clansAdds a lightweight clan system with /clan create, invite, join, leave and info, stores clans in a data file, prefixes members' chat with their tagโฆOnServerInitializedOnPlayerChatOnEntityTakeDamage
- Team auto authorizationAutomatically authorizes a player on every tool cupboard, code lock and auto turret owned by their team when they join a team, and removes thatโฆOnTeamAcceptInviteOnTeamLeaveOnTeamKick
- Team death alertsSends a chat alert to every online teammate when a player dies, including the killer name and the map grid of the death.OnPlayerDeath
Last updated Oct 26, 2026.
Customize team switching cooldown
Customize