How to make a Rust playtime tracker plugin (total and wipe time)
Records session start time on connect and adds elapsed connected time on disconnect, persisted per player ID in oxide/data, with a /playtime command that reports hours and minutes. Uses OnPlayerConnected, OnPlayerDisconnected and OnNewSave.
Rust has no built-in way to see how long a player has actually been online, so admins who want to reward activity, spot inactive regulars or vet staff have to guess. This plugin measures it precisely: it stamps the clock when a player connects, adds the elapsed seconds to a stored total when they disconnect, and keeps a second counter that resets whenever a new save is detected. Both numbers are written to an oxide/data file keyed by Steam ID, so they survive restarts and plugin reloads. Players can type /playtime to see their own hours and minutes; admins with the playtimetracker.lookup permission can pass a name or ID to check someone else. An optional AFK guard stops counting time after a configurable number of seconds without movement, so idle sleepers do not inflate the totals.
The plugin code
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Playtime Tracker", "RustPluginGenerator", "1.0.0")]
[Description("Tracks total and per-wipe connected playtime per player with an optional AFK exclusion.")]
public class PlaytimeTracker : RustPlugin
{
private const string PermLookup = "playtimetracker.lookup";
private const string DataFile = "PlaytimeTracker";
private const float AfkMoveThreshold = 0.5f;
private PluginConfig config;
private Dictionary<string, PlayerRecord> records = new Dictionary<string, PlayerRecord>();
private readonly Dictionary<ulong, Session> sessions = new Dictionary<ulong, Session>();
private class PlayerRecord
{
[JsonProperty("Display name")] public string DisplayName = "";
[JsonProperty("Total seconds")] public double TotalSeconds;
[JsonProperty("Wipe seconds")] public double WipeSeconds;
}
private class Session
{
public double StartedAt;
public double LastActivity;
public Vector3 LastPosition;
}
private class PluginConfig
{
[JsonProperty("Exclude AFK")] public bool ExcludeAFK = true;
[JsonProperty("AFK Timeout Seconds")] public int AfkTimeoutSeconds = 300;
}
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>
{
["YourPlaytime"] = "Your playtime: {0} total, {1} this wipe.",
["PlayerPlaytime"] = "{2}'s playtime: {0} total, {1} this wipe.",
["NotFound"] = "No playtime record found for '{0}'.",
["NoPermission"] = "You do not have permission to look up other players."
}, 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(PermLookup, this);
LoadData();
}
private void Unload()
{
SaveData();
}
private void LoadData()
{
records = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, PlayerRecord>>(DataFile)
?? new Dictionary<string, PlayerRecord>();
}
private void SaveData()
{
Interface.Oxide.DataFileSystem.WriteObject(DataFile, records);
}
private PlayerRecord GetRecord(string userId, string displayName = null)
{
PlayerRecord record;
if (!records.TryGetValue(userId, out record))
{
record = new PlayerRecord();
records[userId] = record;
}
if (!string.IsNullOrEmpty(displayName)) record.DisplayName = displayName;
return record;
}
private void OnPlayerConnected(BasePlayer player)
{
if (player == null || player.IsNpc) return;
GetRecord(player.UserIDString, player.displayName);
double now = Time.realtimeSinceStartup;
sessions[player.userID.Get()] = new Session
{
StartedAt = now,
LastActivity = now,
LastPosition = player.transform.position
};
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
if (player == null || player.IsNpc) return;
Session session;
if (!sessions.TryGetValue(player.userID.Get(), out session)) return;
sessions.Remove(player.userID.Get());
double now = Time.realtimeSinceStartup;
double elapsed = now - session.StartedAt;
if (elapsed <= 0) return;
if (config.ExcludeAFK)
{
double idleAfterActivity = now - session.LastActivity;
if (idleAfterActivity > config.AfkTimeoutSeconds)
{
elapsed -= idleAfterActivity - config.AfkTimeoutSeconds;
}
}
if (elapsed <= 0) return;
PlayerRecord record = GetRecord(player.UserIDString, player.displayName);
record.TotalSeconds += elapsed;
record.WipeSeconds += elapsed;
SaveData();
}
private void OnPlayerTick(BasePlayer player)
{
if (!config.ExcludeAFK || player == null) return;
Session session;
if (!sessions.TryGetValue(player.userID.Get(), out session)) return;
Vector3 position = player.transform.position;
if (Vector3.Distance(position, session.LastPosition) > AfkMoveThreshold)
{
session.LastPosition = position;
session.LastActivity = Time.realtimeSinceStartup;
}
}
private void OnNewSave(string strFilename)
{
foreach (KeyValuePair<string, PlayerRecord> entry in records)
{
entry.Value.WipeSeconds = 0;
}
SaveData();
}
[ChatCommand("playtime")]
private void CmdPlaytime(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (args == null || args.Length == 0)
{
PlayerRecord own = GetRecord(player.UserIDString);
SendReply(player, Msg("YourPlaytime", player.UserIDString, Format(own.TotalSeconds), Format(own.WipeSeconds)));
return;
}
if (!permission.UserHasPermission(player.UserIDString, PermLookup))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
string target = args[0];
BasePlayer online = BasePlayer.FindAwakeOrSleeping(target);
if (online != null)
{
PlayerRecord rec = GetRecord(online.UserIDString, online.displayName);
SendReply(player, Msg("PlayerPlaytime", player.UserIDString, Format(rec.TotalSeconds), Format(rec.WipeSeconds), rec.DisplayName));
return;
}
PlayerRecord offline;
if (records.TryGetValue(target, out offline))
{
SendReply(player, Msg("PlayerPlaytime", player.UserIDString, Format(offline.TotalSeconds), Format(offline.WipeSeconds), offline.DisplayName));
return;
}
SendReply(player, Msg("NotFound", player.UserIDString, target));
}
private string Format(double seconds)
{
long totalMinutes = (long)(seconds / 60.0);
long hours = totalMinutes / 60;
long minutes = totalMinutes % 60;
return string.Format("{0}h {1}m", hours, minutes);
}
}
}Hooks used: OnPlayerConnected, OnPlayerDisconnected, OnPlayerTick, OnNewSave
Config file
Created automatically on first load at oxide/config/PlaytimeTracker.json.
{
"Exclude AFK": true,
"AFK Timeout Seconds": 300
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/playtime [player] | Shows your total and current-wipe playtime; with an argument, shows another player's playtime. | playtimetracker.lookup |
| Permission | Grants |
|---|---|
playtimetracker.lookup | Allows using /playtime with a player name or Steam ID to view someone else's playtime. |
How the code works
Starting a session on connect
OnPlayerConnectedis the entry point. The plugin creates or fetches the player's record in the in-memoryrecordsdictionary and sets the display name stored on it, then writes aSessioninto thesessionsdictionary keyed by the player's Steam ID. The session holds three values:StartedAtandLastActivityset toUnityEngine.Time.realtimeSinceStartup, andLastPositionset toplayer.transform.position.Nothing is written to disk on connect. The join timestamp only lives in memory, so a crash mid-session leaves no half-record behind.
Closing a session on disconnect
OnPlayerDisconnectedlooks the session up insessionsand removes it. It computeselapsedas the currentTime.realtimeSinceStartupminusStartedAt, applies the AFK adjustment described below, and returns early if the result is not positive.Otherwise
GetRecordreturns the stored record, the plugin addselapsedto bothTotalSecondsandWipeSeconds, and callsSaveDatato writeoxide/data/PlaytimeTrackerthroughInterface.Oxide.DataFileSystem. Thereasonparameter is accepted but not used.The AFK allowance
With
Exclude AFKenabled,OnPlayerTickcompares the player's currenttransform.positionagainstsession.LastPositionwithVector3.Distance. If the movement exceedsAfkMoveThreshold(0.5 units), it updatesLastPositionand setsLastActivityto the currentTime.realtimeSinceStartup.On disconnect, if
now - LastActivityis larger thanAFK Timeout Seconds, that surplus is subtracted fromelapsedbefore it is credited. This is movement-based only: fishing, vending or standing still past the timeout count as AFK, so keep the default of 300 seconds generous or disable the option.Resetting on a new save
OnNewSavefires when a new save is detected. It iterates every entry in therecordsdictionary, setsWipeSecondson eachPlayerRecordto 0 and callsSaveData.TotalSecondsis left untouched, so/playtimecan show long-term history next to the current-wipe figure.The plugin does not schedule or trigger this reset itself. If it does not run for your wipe workflow, stop the server and delete
oxide/data/PlaytimeTracker.json; that also clears total playtime, so back the file up first if you want the history.Reporting via /playtime
CmdPlaytimeis registered with[ChatCommand("playtime")]. With no argument it reads the caller's record viaGetRecordand replies with theYourPlaytimemessage. With an argument it first checkspermission.UserHasPermissionforplaytimetracker.lookup, then triesBasePlayer.FindAwakeOrSleepingto resolve an online or sleeping player.If that fails, it looks the raw argument up directly in the
recordsdictionary, which covers offline players by Steam ID. A miss produces theNotFoundmessage. Both replies are formatted byFormat, which divides seconds into hours and minutes.Configuration
PluginConfigholds two fields:Exclude AFK(bool) andAFK Timeout Seconds(int).LoadConfigreads the file with a try/catch; if deserialization returns null or throws,PrintWarningreports it andLoadDefaultConfigrestores the defaults.SaveConfigalways rewrites the file, so a repaired config is written back on startup.The timeout is read at disconnect time, so changing it mid-wipe only affects sessions still open when the value changes.
Install it on your server
Save PlaytimeTracker.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/PlaytimeTracker.json(Carbon:carbon/configs/PlaytimeTracker.json). After editing, reload it:oxide.reload PlaytimeTracker c.reload PlaytimeTracker # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default playtimetracker.lookup c.grant group default playtimetracker.lookup # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Reward playtime with scrap
Adds a timer that grants scrap per completed hour of connected play.
Generate this versionWipe leaderboard
Prints the top ten players by wipe playtime.
Generate this versionMinimum playtime gate
Blocks a command until the player has enough total time.
Generate this version
FAQ
Does the AFK timeout work for players who are fishing or in a vending machine?
No. The check is based on position changes only, so any activity that keeps a player still past AFK Timeout Seconds is counted as AFK and removed from the session. Raise the timeout or set Exclude AFK to false if that matters for your server.
Will time be lost if the server crashes?
The session in progress is lost because totals are only written on a clean disconnect. Earlier completed sessions are safe in the oxide/data/PlaytimeTracker file. If crashes are common, keep sessions short or ask a developer to persist partial sessions on a timer.
How do I reset wipe playtime manually?
OnNewSave clears the wipe counter automatically when a new save is detected. To reset without that, stop the server, delete oxide/data/PlaytimeTracker.json, and start again; total playtime is wiped too, so back the file up first if you want to keep history.
Can I look up offline players?
Yes, by Steam ID. /playtime <id> falls back to the records dictionary, which stores every player the plugin has seen. Name lookups use BasePlayer.FindAwakeOrSleeping and only match players currently connected or sleeping.
How do I install it on Oxide or Carbon?
Place PlaytimeTracker.cs in oxide/plugins, then type oxide.reload PlaytimeTracker in the console or let Oxide load it on restart. Carbon reads the same folder and file. Grant playtimetracker.lookup with oxide.grant user <name-or-id> playtimetracker.lookup.
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 stats plugins
- Death cause statsThis plugin records every player death, classifies the cause from the HitInfo passed to OnPlayerDeath, and stores running totals per player soโฆOnServerInitializedOnPlayerDeath
- Gather statisticsTracks wood, stone, metal ore, sulfur ore and cloth gathered per player for the current wipe, persists the totals in oxide/data, and shows them withโฆOnServerInitializedOnDispenserGatheredOnCollectiblePickup
- Last seen commandRecords a timestamp for every player on disconnect and answers /seen <name> with a relative time such as '2 hours ago' or 'online now'.OnServerInitializedOnPlayerConnectedOnPlayerDisconnected
Last updated Oct 26, 2026.
Customize playtime tracker
Customize