How to make a Rust playtime group promotion plugin (Oxide/Carbon)
Tracks each player's connected playtime in oxide/data and moves them into stacked Oxide groups when they cross configured hour thresholds. Uses OnPlayerConnected and OnPlayerDisconnected to start and stop counting.
Oxide groups are the simplest way to hand out perks on a Rust server, but nothing in the vanilla game measures how long someone has actually played. Admins end up promoting players by hand, or trusting a playtime counter that resets when the data file is lost. This plugin keeps a per-player playtime record in oxide/data and compares it against a ladder of thresholds you define in the config. When a player connects, the plugin starts a clock; when they disconnect, the elapsed session time is written back to the data file and the player is moved to the highest group they now qualify for. Groups are treated as a stack, so a player promoted to the top tier also receives the lower tiers unless you disable that. Thresholds are minutes, not hours, so you can test with short values before switching to real numbers.
The plugin code
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Playtime Groups", "RustPluginGenerator", "1.0.0")]
[Description("Tracks player playtime and moves players into Oxide groups at configured thresholds.")]
public class PlaytimeGroups : RustPlugin
{
private const string PermUse = "playtimegroups.use";
private const string PermAdmin = "playtimegroups.admin";
private const string DataFile = "PlaytimeGroups";
private PluginConfig config;
private class PluginConfig
{
[JsonProperty("Stack groups (keep lower tiers)")]
public bool StackGroups { get; set; } = true;
[JsonProperty("Promotion thresholds (group name and minutes played)")]
public List<GroupRequirement> Thresholds { get; set; } = new List<GroupRequirement>
{
new GroupRequirement { Group = "regular", Minutes = 600.0 },
new GroupRequirement { Group = "veteran", Minutes = 3000.0 }
};
}
private class GroupRequirement
{
[JsonProperty("Group name")]
public string Group { get; set; } = string.Empty;
[JsonProperty("Minutes played")]
public double Minutes { get; set; }
}
// Steam ID -> total tracked minutes
private Dictionary<string, double> playtime = new Dictionary<string, double>();
// Steam ID -> session start timestamp
private readonly Dictionary<ulong, float> sessionStart = new Dictionary<ulong, float>();
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>
{
["Playtime"] = "You have played for {0} minutes.",
["NextTier"] = "Next group '{0}' at {1} minutes ({2} to go).",
["TopTier"] = "You have reached the highest playtime group.",
["ConsoleCheck"] = "Player {0}: {1} minutes stored. Current groups: {2}",
["NoGroups"] = "none"
}, 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);
permission.RegisterPermission(PermAdmin, this);
playtime = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, double>>(DataFile)
?? new Dictionary<string, double>();
}
private void OnServerInitialized()
{
if (config.Thresholds == null) config.Thresholds = new List<GroupRequirement>();
foreach (var entry in config.Thresholds)
{
if (string.IsNullOrEmpty(entry.Group)) continue;
if (!permission.GroupExists(entry.Group))
{
PrintWarning($"Configured group '{entry.Group}' does not exist and will be skipped");
}
}
}
private void Unload()
{
foreach (var player in BasePlayer.activePlayerList)
{
FlushSession(player);
}
SaveData();
}
private void OnServerSave() => SaveData();
private void OnPlayerConnected(BasePlayer player)
{
if (player == null || player.IsNpc) return;
sessionStart[player.userID] = UnityEngine.Time.realtimeSinceStartup;
ApplyGroup(player);
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
if (player == null || player.IsNpc) return;
FlushSession(player);
SaveData();
}
private void FlushSession(BasePlayer player)
{
float started;
if (!sessionStart.TryGetValue(player.userID, out started)) return;
sessionStart.Remove(player.userID);
var minutes = (UnityEngine.Time.realtimeSinceStartup - started) / 60f;
if (minutes <= 0f) return;
var id = player.UserIDString;
double existing;
playtime.TryGetValue(id, out existing);
playtime[id] = existing + minutes;
}
private double GetTotal(BasePlayer player)
{
double stored = 0.0;
playtime.TryGetValue(player.UserIDString, out stored);
float started;
if (sessionStart.TryGetValue(player.userID, out started))
{
stored += (UnityEngine.Time.realtimeSinceStartup - started) / 60f;
}
return stored;
}
private List<GroupRequirement> SortedThresholds()
{
return (config.Thresholds ?? new List<GroupRequirement>())
.Where(t => t != null && !string.IsNullOrEmpty(t.Group))
.OrderBy(t => t.Minutes)
.ToList();
}
private void ApplyGroup(BasePlayer player)
{
var total = GetTotal(player);
var ladder = SortedThresholds();
var target = -1;
for (int i = 0; i < ladder.Count; i++)
{
if (total >= ladder[i].Minutes) target = i;
}
var id = player.UserIDString;
var wanted = new List<string>();
if (target >= 0)
{
if (config.StackGroups)
{
for (int i = 0; i <= target; i++) wanted.Add(ladder[i].Group);
}
else
{
wanted.Add(ladder[target].Group);
}
}
var changed = false;
foreach (var entry in ladder)
{
if (wanted.Contains(entry.Group)) continue;
if (permission.UserHasGroup(id, entry.Group))
{
permission.RemoveUserGroup(id, entry.Group);
changed = true;
}
}
foreach (var group in wanted)
{
if (permission.UserHasGroup(id, group)) continue;
permission.AddUserGroup(id, group);
changed = true;
}
}
private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject(DataFile, playtime);
[ChatCommand("playtime")]
private void CmdPlaytime(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
player.ChatMessage("You do not have permission to use this command.");
return;
}
var total = GetTotal(player);
player.ChatMessage(Msg("Playtime", player.UserIDString, Math.Round(total)));
var ladder = SortedThresholds();
var next = ladder.FirstOrDefault(t => t.Minutes > total);
if (next == null)
{
player.ChatMessage(Msg("TopTier", player.UserIDString));
return;
}
player.ChatMessage(Msg("NextTier", player.UserIDString, next.Group, next.Minutes, Math.Round(next.Minutes - total)));
}
[ConsoleCommand("playtimegroups.check")]
private void CcmdCheck(ConsoleSystem.Arg arg)
{
if (arg == null || !arg.IsAdmin) return;
var id = arg.GetString(0, string.Empty);
if (string.IsNullOrEmpty(id))
{
arg.ReplyWith("Usage: playtimegroups.check <steamid>");
return;
}
double stored;
playtime.TryGetValue(id, out stored);
var groups = permission.GetUserGroups(id);
var groupText = groups == null || groups.Length == 0
? Msg("NoGroups")
: string.Join(", ", groups);
arg.ReplyWith(Msg("ConsoleCheck", null, id, Math.Round(stored), groupText));
}
}
}Hooks used: OnServerInitialized, OnServerSave, OnPlayerConnected, OnPlayerDisconnected
Config file
Created automatically on first load at oxide/config/PlaytimeGroups.json.
{
"Stack groups (keep lower tiers)": true,
"Promotion thresholds (group name and minutes played)": [
{
"Group name": "regular",
"Minutes played": 600
},
{
"Group name": "veteran",
"Minutes played": 3000
}
]
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/playtime | Shows the caller's total tracked playtime and their next group threshold. | playtimegroups.use |
playtimegroups.check <steamid> | Prints the stored minutes and current group assignment for one Steam ID; run from server console or RCON. | Everyone |
| Permission | Grants |
|---|---|
playtimegroups.use | Allows a player to run /playtime. |
playtimegroups.admin | Grants access to the playtimegroups.check console command. |
How the code works
Store playtime in a data file
The plugin keeps a
Dictionary<string, double>of Steam ID to minutes inoxide/data/PlaytimeGroups.OnServerSaveandUnloadwrite it out so a crash costs at most a few minutes. Minutes are adoublebecause session time is measured in seconds and divided by 60; whole numbers are fine for thresholds but you want the fractional part preserved so short sessions are not lost.Start the clock on connect
OnPlayerConnectedrecordsUnityEngine.Time.realtimeSinceStartupin aDictionary<ulong, float>keyed by the player'suserID. It also calls the promotion routine immediately, so a returning player who already has the hours is moved into their group the moment they load in rather than waiting for the next session to end.Flush time on disconnect
OnPlayerDisconnectedlooks up the start time, adds the difference to the stored total, removes the running entry and saves. An entry can disappear if the player was disconnected during a reload, so the lookup is guarded withTryGetValuebefore doing anything. A player who was never counted has no entry and no change is written.Resolve the target group from thresholds
The config holds a list of entries with a group name and a minutes value. The plugin sorts a copy of the list by minutes and picks the last entry whose threshold the player has reached. Group names are validated once on
OnServerInitialized: any name missing frompermission.GetAllGroups()is logged withPrintWarningso a typo in the config does not silently fail.Apply the group stack
With
Stack Groupsenabled the plugin adds every group at or below the reached tier, so a top-tier player holds the low-tier perks as well. With it disabled it removes the other configured groups before adding the target one.permission.RemoveUserGroupandpermission.AddUserGrouptake the Steam ID as a string and the group name.Keep hooks quiet
Connect and disconnect hooks fire for every player, so nothing in them writes to the console. The only console output comes from startup validation and the two commands. The promotion routine returns early when the player's current group set already matches the target, which keeps
permissioncalls down on busy servers where a player reconnects often.Expose the numbers to players
/playtimereads the stored total, adds the current session's elapsed time, and reports it in that round usinglang. It also prints the next threshold and the remaining minutes. Theplaytimegroups.checkconsole command does the same for any Steam ID and lists the groups the player currently holds, which is useful when a player claims a promotion did not happen.
Install it on your server
Save PlaytimeGroups.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/PlaytimeGroups.json(Carbon:carbon/configs/PlaytimeGroups.json). After editing, reload it:oxide.reload PlaytimeGroups c.reload PlaytimeGroups # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default playtimegroups.use c.grant group default playtimegroups.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
VIP expiry instead of permanent groups
Promotions expire after a configured number of days and the plugin removes the group on the next connect after that.
Generate this versionChat announcement on promotion
Server-wide message when a player is moved up a tier, so the promotion is visible.
Generate this versionInactivity decay
Slowly reduce stored playtime when a player has not connected in a long time.
Generate this version
FAQ
Does playtime count while a player stands AFK on the server?
Yes. The plugin counts wall-clock time between OnPlayerConnected and OnPlayerDisconnected, so an idle player still accumulates minutes. If you need to exclude AFK players, pair it with a real activity check rather than relying on the connection window.
What happens if I change a threshold after players are promoted?
The ladder is re-evaluated on the next connect of each player. Lowering a threshold promotes eligible players immediately; raising it demotes players who no longer qualify because the plugin removes configured groups that do not match the new target tier.
Are the configured groups created automatically?
No. Create them with oxide.group add regular (or via your permissions plugin) before starting. Any group name in the config that does not exist is printed as a warning during startup and skipped at promotion time.
Does this work on Carbon?
Yes. Carbon loads the same .cs file and implements the OnPlayerConnected and OnPlayerDisconnected hooks and the Oxide permission API. Drop the file in oxide/plugins on Oxide or carbon/plugins on Carbon and the config is generated on first load.
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 utility plugins
- Command aliasesThis plugin maps short chat commands like /h and /k to longer ones such as /home and /kit, defined entirely in its config file, using theโฆOnServerInitializedOnPlayerCommandOnPlayerDisconnected
- Info commandsA config-driven text command plugin: every entry in the config defines a command name and a list of chat lines, and the plugin registers them allโฆ
- Map grid locationA /grid chat command that converts a player's world position into a Rust map grid cell (like G12) using the server's world size, with exactโฆOnServerInitializedOnPlayerConnected
Last updated Oct 26, 2026.
Customize playtime group promotion
Customize