Skip to content
RustPluginGenerator

How to make a Rust playtime rewards plugin (AFK-safe, Oxide)

Pays players configured items every N minutes of active play, skips anyone who has not moved during the interval, and applies a permission-based multiplier for VIPs. Built on OnPlayerConnected, OnPlayerDisconnected and a repeating Oxide timer.

Rust has no built-in playtime tracking, so a rewards system has to keep its own per-player clock. The obvious trap is AFK farming: a player who parks in their base with the client open should not get paid the same as someone actually playing. This plugin solves both problems with a single repeating timer. When a player connects, the plugin records their starting position and starts counting real seconds. On every tick it compares the current position with the stored one; if the player moved far enough to count as active, the elapsed time is added to a per-session accumulator. Once the accumulator passes the configured interval, the plugin hands out the configured item set, scales stack sizes by any multiplier permission the player holds, and sends a chat notice. Disconnecting clears the session; nothing is written to disk, so the counters reset when the plugin reloads, which is intentional for a per-session reward.

The plugin code

PlaytimeRewards.cs
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Playtime Rewards", "RustPluginGenerator", "1.0.0")]
    [Description("Pays configured items every N minutes of active play, skipping AFK players.")]
    public class PlaytimeRewards : RustPlugin
    {
        private const string PermUse = "playtimerewards.use";
        private const string PermVip = "playtimerewards.vip";
        private const string PermAdmin = "playtimerewards.admin";

        private class PluginConfig
        {
            [JsonProperty("Seconds between activity checks")]
            public float TickSeconds = 5f;

            [JsonProperty("Minutes of active play per reward")]
            public float MinutesPerReward = 30f;

            [JsonProperty("Minimum distance in metres to count as active")]
            public float AfkDistance = 1f;

            [JsonProperty("VIP reward multiplier")]
            public float VipMultiplier = 2f;

            [JsonProperty("Rewards")]
            public List<RewardEntry> Rewards = new List<RewardEntry>
            {
                new RewardEntry { Shortname = "scrap", Amount = 100, Skin = 0 },
                new RewardEntry { Shortname = "metal.fragments", Amount = 50, Skin = 0 }
            };
        }

        private class RewardEntry
        {
            [JsonProperty("Shortname")]
            public string Shortname = "scrap";

            [JsonProperty("Amount")]
            public int Amount = 100;

            [JsonProperty("Skin")]
            public ulong Skin = 0;
        }

        private class Session
        {
            public string UserId;
            public Vector3 LastPosition;
            public float Accumulated;
            public float Bonus;
        }

        private PluginConfig config;
        private readonly Dictionary<string, Session> sessions = new Dictionary<string, Session>();
        private Timer tickTimer;

        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>
            {
                ["RewardReceived"] = "Playtime reward: {0}",
                ["Progress"] = "Active playtime this session: {0}s of {1}s needed for the next reward.",
                ["ItemLine"] = "{0} x{1}",
                ["NoPermission"] = "You do not have permission to use this command.",
                ["Credited"] = "Credited {0} seconds to {1}.",
                ["PlayerNotFound"] = "No player found with that name or ID."
            }, 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(PermVip, this);
            permission.RegisterPermission(PermAdmin, this);
        }

        private void OnServerInitialized()
        {
            foreach (var entry in config.Rewards)
            {
                if (ItemManager.FindItemDefinition(entry.Shortname) == null)
                    PrintWarning($"Unknown shortname in reward list: {entry.Shortname}");
            }

            tickTimer = timer.Every(Mathf.Max(1f, config.TickSeconds), Tick);
        }

        private void Unload()
        {
            tickTimer?.Destroy();
            sessions.Clear();
        }

        private void OnPlayerConnected(BasePlayer player)
        {
            if (player == null) return;
            sessions[player.UserIDString] = new Session
            {
                UserId = player.UserIDString,
                LastPosition = player.transform.position
            };
        }

        private void OnPlayerDisconnected(BasePlayer player, string reason)
        {
            if (player == null) return;
            sessions.Remove(player.UserIDString);
        }

        private void Tick()
        {
            if (sessions.Count == 0) return;
            float tick = Mathf.Max(1f, config.TickSeconds);

            foreach (var player in BasePlayer.activePlayerList)
            {
                if (player == null || player.IsNpc || !player.IsConnected) continue;
                if (player.IsDead() || player.IsSleeping()) continue;

                Session session;
                if (!sessions.TryGetValue(player.UserIDString, out session)) continue;

                Vector3 position = player.transform.position;
                if (Vector3.Distance(position, session.LastPosition) >= config.AfkDistance)
                {
                    session.LastPosition = position;
                    session.Accumulated += tick;
                }

                float needed = Mathf.Max(1f, config.MinutesPerReward) * 60f;
                session.Accumulated += session.Bonus;
                session.Bonus = 0f;

                if (session.Accumulated >= needed)
                {
                    session.Accumulated -= needed;
                    PayReward(player);
                }
            }
        }

        private void PayReward(BasePlayer player)
        {
            float multiplier = permission.UserHasPermission(player.UserIDString, PermVip)
                ? Mathf.Max(0.1f, config.VipMultiplier)
                : 1f;

            var lines = new List<string>();
            foreach (var entry in config.Rewards)
            {
                int amount = Mathf.Max(1, Mathf.RoundToInt(entry.Amount * multiplier));
                var item = ItemManager.CreateByName(entry.Shortname, amount, entry.Skin);
                if (item == null) continue;

                player.GiveItem(item);
                lines.Add(Msg("ItemLine", player.UserIDString, item.info.displayName.english, amount));
            }

            if (lines.Count == 0) return;

            float minutes = Mathf.Max(1f, config.MinutesPerReward);
            PrintToChat(player, Msg("RewardReceived", player.UserIDString, string.Join(", ", lines.ToArray())) + $" (+{minutes:0} min)");
        }

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

            Session session;
            float accumulated = sessions.TryGetValue(player.UserIDString, out session) ? session.Accumulated : 0f;
            float needed = Mathf.Max(1f, config.MinutesPerReward) * 60f;
            SendReply(player, Msg("Progress", player.UserIDString, Mathf.FloorToInt(accumulated), Mathf.FloorToInt(needed)));
        }

        [ConsoleCommand("playtime.give")]
        private void CcmdGive(ConsoleSystem.Arg arg)
        {
            if (!arg.IsAdmin && (arg.Player() == null || !permission.UserHasPermission(arg.Player().UserIDString, PermAdmin)))
            {
                arg.ReplyWith(Msg("NoPermission"));
                return;
            }

            string target = arg.GetString(0, "");
            float seconds = arg.GetFloat(1, 0f);
            if (string.IsNullOrEmpty(target) || seconds <= 0f)
            {
                arg.ReplyWith("playtime.give <nameOrId> <seconds>");
                return;
            }

            var player = BasePlayer.FindAwakeOrSleeping(target);
            if (player == null)
            {
                arg.ReplyWith(Msg("PlayerNotFound"));
                return;
            }

            Session session;
            if (!sessions.TryGetValue(player.UserIDString, out session))
            {
                session = new Session { UserId = player.UserIDString, LastPosition = player.transform.position };
                sessions[player.UserIDString] = session;
            }

            session.Bonus += seconds;
            arg.ReplyWith(Msg("Credited", null, seconds, player.displayName));
        }
    }
}

Hooks used: OnServerInitialized, OnPlayerConnected, OnPlayerDisconnected

Config file

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

PlaytimeRewards.json
{
  "Seconds between activity checks": 5,
  "Minutes of active play per reward": 30,
  "Minimum distance in metres to count as active": 1,
  "VIP reward multiplier": 2,
  "Rewards": [
    {
      "Shortname": "scrap",
      "Amount": 100,
      "Skin": 0
    },
    {
      "Shortname": "metal.fragments",
      "Amount": 50,
      "Skin": 0
    }
  ]
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/playtimeShows how many seconds you have accumulated in the current session and how many are needed for the next reward.playtimerewards.use
playtime.give <nameOrId> <seconds>Credits the given player with extra active seconds, which can push them over the reward threshold.playtimerewards.admin
Permissions
PermissionGrants
playtimerewards.useAllows the player to run /playtime and see their own progress.
playtimerewards.vipApplies the VIP reward multiplier from the config to every item handed out.
playtimerewards.adminAllows the playtime.give console command.

How the code works

  1. Track sessions on connect and disconnect

    OnPlayerConnected creates a session object holding the player's user ID string, the last recorded position, the accumulated active seconds and the bonus seconds credited by admins. OnPlayerDisconnected removes the session so a reconnecting player starts fresh. Both hooks null-check player first, because the methods can fire with a null entity in edge cases during shutdown. No timers are created per player; the single global timer below drives every session.

  2. Measure activity by distance moved

    The plugin stores one timer started in OnServerInitialized with timer.Every(config.TickSeconds, ...). Each tick walks BasePlayer.activePlayerList, skips IsNpc and dead or sleeping players, and uses Vector3.Distance between the current transform.position and the session's stored position. If the distance exceeds config.AfkDistance, the player counts as active for this tick: the stored position is refreshed and TickSeconds is added to accumulated. If not, the position is kept so a slow drift still builds up and eventually registers.

  3. Pay out when the interval is reached

    Once accumulated is at least config.MinutesPerReward * 60, the plugin subtracts the interval instead of zeroing the counter, so leftover seconds carry over and long ticks do not lose time. It then builds each entry from config.Rewards with ItemManager.CreateByName(Shortname, amount, Skin), multiplying Amount by config.VipMultiplier when the player has playtimerewards.vip. Null items (unknown shortname) are skipped with a single startup-time warning path rather than spamming chat.

  4. Deliver items and announce in chat

    Each item is passed to player.GiveItem(item), which adds it to the inventory or drops it at the player's feet when the inventory is full. The chat notice goes through the lang key RewardReceived with the item names joined by a helper, so the text can be translated or reworded without touching code. A second key, Progress, backs the /playtime command and prints accumulated seconds against the configured interval.

  5. Permission multipliers and admin credit

    playtimerewards.vip and playtimerewards.admin are registered in Init along with playtimerewards.use. The console command playtime.give takes a name or ID and a number of seconds, resolves the player with BasePlayer.FindAwakeOrSleeping, and adds the seconds to the session's bonus field. The tick loop adds bonus to accumulated before the threshold check, so admins can compensate players for downtime without waiting out a full interval.

  6. Config, lang and cleanup

    LoadDefaultConfig writes TickSeconds, MinutesPerReward, AfkDistance, VipMultiplier and the Rewards list; the reward list defaults to a handful of scrap and components. Messages live in LoadDefaultMessages with RewardReceived, Progress and NoPermission. Unload destroys the repeating timer and, since nothing is persisted, that is all the cleanup required. The config is re-read defensively so a hand-edited file that fails to parse falls back to defaults with a warning.

Install it on your server

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

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

Variations

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

  • Economy plugin payout instead of items

    Pay a currency balance per interval through the Economics plugin instead of physical items.

    Generate this version
  • Scaling reward tiers

    Increase the payout as a session gets longer, so a four-hour session pays more per interval than the first one.

    Generate this version
  • Daily cap per player

    Limit how many rewards a player can earn per real day and reset the counter at midnight UTC.

    Generate this version

FAQ

How does the plugin decide a player is AFK?

It compares the player's transform.position with the position stored on the previous tick. If the distance is below AfkDistance (default 1.0 metre) no seconds are credited, so standing still, sleeping, or browsing the map without walking earns nothing. Set the value higher if you want looser detection.

Does playtime carry over between sessions?

No. Sessions are held in memory and are dropped on disconnect or plugin reload. That keeps the plugin free of data files and makes it predictable: a reward is due after MinutesPerReward of continuous active play in one session. If you need lifetime totals, store them with Interface.Oxide.DataFileSystem.

Why did my reward give the wrong amount?

Check the shortname in Rewards. ItemManager.CreateByName returns null for unknown shortnames and the plugin silently skips those entries. Also confirm the VIP multiplier: a player with playtimerewards.vip gets Amount * VipMultiplier, rounded up, so a 1x stack can become 2x.

How do I install it on Oxide or Carbon?

Drop the compiled .cs file into oxide/plugins, wait for compilation, then edit oxide/config/PlaytimeRewards.json and reload the plugin with oxide.reload PlaytimeRewards. Carbon loads the same file from the same folder. Grant permissions with oxide.grant user <steamid> playtimerewards.vip.

Can players lose rewards if their inventory is full?

No. BasePlayer.GiveItem drops the item at the player's position when the inventory has no room, so the payout still happens. If you would rather skip players with full inventories, check player.inventory.containerMain.IsFull() before creating the items.

Customize it for your server

Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.

Prompt: Write a Rust Oxide playtime rewards plugin: every 30 minutes of active play give 100 scrap and 50 metal fragments, skip players who moved less than 1 metre since the last tick, apply 2x for permission playtimerewards.vip, and send a chat notice.
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 rewards

Customize