Skip to content
RustPluginGenerator

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

PlaytimeGroups.cs
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.

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

Chat and console commands
CommandWhat it doesPermission
/playtimeShows 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
Permissions
PermissionGrants
playtimegroups.useAllows a player to run /playtime.
playtimegroups.adminGrants access to the playtimegroups.check console command.

How the code works

  1. Store playtime in a data file

    The plugin keeps a Dictionary<string, double> of Steam ID to minutes in oxide/data/PlaytimeGroups. OnServerSave and Unload write it out so a crash costs at most a few minutes. Minutes are a double because 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.

  2. Start the clock on connect

    OnPlayerConnected records UnityEngine.Time.realtimeSinceStartup in a Dictionary<ulong, float> keyed by the player's userID. 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.

  3. Flush time on disconnect

    OnPlayerDisconnected looks 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 with TryGetValue before doing anything. A player who was never counted has no entry and no change is written.

  4. 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 from permission.GetAllGroups() is logged with PrintWarning so a typo in the config does not silently fail.

  5. Apply the group stack

    With Stack Groups enabled 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.RemoveUserGroup and permission.AddUserGroup take the Steam ID as a string and the group name.

  6. 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 permission calls down on busy servers where a player reconnects often.

  7. Expose the numbers to players

    /playtime reads the stored total, adds the current session's elapsed time, and reports it in that round using lang. It also prints the next threshold and the remaining minutes. The playtimegroups.check console 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

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

    Give it to every player, or swap default for a group such as vip: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 version
  • Chat announcement on promotion

    Server-wide message when a player is moved up a tier, so the promotion is visible.

    Generate this version
  • Inactivity 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.

Prompt: Create a Rust Oxide plugin that tracks per-player playtime in oxide/data and auto-assigns Oxide groups at configured minute thresholds, with stacked groups, /playtime command and a console check command.
Customize for my server

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

Last updated Oct 26, 2026.

Customize playtime group promotion

Customize