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
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.
{
"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
| Command | What it does | Permission |
|---|---|---|
/playtime | Shows 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 |
| Permission | Grants |
|---|---|
playtimerewards.use | Allows the player to run /playtime and see their own progress. |
playtimerewards.vip | Applies the VIP reward multiplier from the config to every item handed out. |
playtimerewards.admin | Allows the playtime.give console command. |
How the code works
Track sessions on connect and disconnect
OnPlayerConnectedcreates 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.OnPlayerDisconnectedremoves the session so a reconnecting player starts fresh. Both hooks null-checkplayerfirst, 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.Measure activity by distance moved
The plugin stores one
timerstarted inOnServerInitializedwithtimer.Every(config.TickSeconds, ...). Each tick walksBasePlayer.activePlayerList, skipsIsNpcand dead or sleeping players, and usesVector3.Distancebetween the currenttransform.positionand the session's stored position. If the distance exceedsconfig.AfkDistance, the player counts as active for this tick: the stored position is refreshed andTickSecondsis added toaccumulated. If not, the position is kept so a slow drift still builds up and eventually registers.Pay out when the interval is reached
Once
accumulatedis at leastconfig.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 fromconfig.RewardswithItemManager.CreateByName(Shortname, amount, Skin), multiplyingAmountbyconfig.VipMultiplierwhen the player hasplaytimerewards.vip. Null items (unknown shortname) are skipped with a single startup-time warning path rather than spamming chat.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 keyRewardReceivedwith the item names joined by a helper, so the text can be translated or reworded without touching code. A second key,Progress, backs the/playtimecommand and prints accumulated seconds against the configured interval.Permission multipliers and admin credit
playtimerewards.vipandplaytimerewards.adminare registered inInitalong withplaytimerewards.use. The console commandplaytime.givetakes a name or ID and a number of seconds, resolves the player withBasePlayer.FindAwakeOrSleeping, and adds the seconds to the session'sbonusfield. The tick loop addsbonustoaccumulatedbefore the threshold check, so admins can compensate players for downtime without waiting out a full interval.Config, lang and cleanup
LoadDefaultConfigwritesTickSeconds,MinutesPerReward,AfkDistance,VipMultiplierand theRewardslist; the reward list defaults to a handful of scrap and components. Messages live inLoadDefaultMessageswithRewardReceived,ProgressandNoPermission.Unloaddestroys 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
Save PlaytimeRewards.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/PlaytimeRewards.json(Carbon:carbon/configs/PlaytimeRewards.json). After editing, reload it:oxide.reload PlaytimeRewards c.reload PlaytimeRewards # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip: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 versionScaling 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 versionDaily 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.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More economy plugins
- BountiesA scrap bounty system where players fund a pool on a target and the killer collects it, using the OnEntityDeath hook plus a data file forโฆOnServerInitializedOnEntityDeath
- Chat shopA chat-driven shop where players spend scrap from their inventory to buy configured items.
- Daily rewardsAdds a /daily command that lets each player claim one reward from a configurable weighted list every 24 hours, with a streak bonus that scales whileโฆOnServerInitializedOnServerSave
Last updated Oct 26, 2026.
Customize playtime rewards
Customize