Skip to content
RustPluginGenerator

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

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

PlaytimeTracker.json
{
  "Exclude AFK": true,
  "AFK Timeout Seconds": 300
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/playtime [player]Shows your total and current-wipe playtime; with an argument, shows another player's playtime.playtimetracker.lookup
Permissions
PermissionGrants
playtimetracker.lookupAllows using /playtime with a player name or Steam ID to view someone else's playtime.

How the code works

  1. Starting a session on connect

    OnPlayerConnected is the entry point. The plugin creates or fetches the player's record in the in-memory records dictionary and sets the display name stored on it, then writes a Session into the sessions dictionary keyed by the player's Steam ID. The session holds three values: StartedAt and LastActivity set to UnityEngine.Time.realtimeSinceStartup, and LastPosition set to player.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.

  2. Closing a session on disconnect

    OnPlayerDisconnected looks the session up in sessions and removes it. It computes elapsed as the current Time.realtimeSinceStartup minus StartedAt, applies the AFK adjustment described below, and returns early if the result is not positive.

    Otherwise GetRecord returns the stored record, the plugin adds elapsed to both TotalSeconds and WipeSeconds, and calls SaveData to write oxide/data/PlaytimeTracker through Interface.Oxide.DataFileSystem. The reason parameter is accepted but not used.

  3. The AFK allowance

    With Exclude AFK enabled, OnPlayerTick compares the player's current transform.position against session.LastPosition with Vector3.Distance. If the movement exceeds AfkMoveThreshold (0.5 units), it updates LastPosition and sets LastActivity to the current Time.realtimeSinceStartup.

    On disconnect, if now - LastActivity is larger than AFK Timeout Seconds, that surplus is subtracted from elapsed before 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.

  4. Resetting on a new save

    OnNewSave fires when a new save is detected. It iterates every entry in the records dictionary, sets WipeSeconds on each PlayerRecord to 0 and calls SaveData. TotalSeconds is left untouched, so /playtime can 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.

  5. Reporting via /playtime

    CmdPlaytime is registered with [ChatCommand("playtime")]. With no argument it reads the caller's record via GetRecord and replies with the YourPlaytime message. With an argument it first checks permission.UserHasPermission for playtimetracker.lookup, then tries BasePlayer.FindAwakeOrSleeping to resolve an online or sleeping player.

    If that fails, it looks the raw argument up directly in the records dictionary, which covers offline players by Steam ID. A miss produces the NotFound message. Both replies are formatted by Format, which divides seconds into hours and minutes.

  6. Configuration

    PluginConfig holds two fields: Exclude AFK (bool) and AFK Timeout Seconds (int). LoadConfig reads the file with a try/catch; if deserialization returns null or throws, PrintWarning reports it and LoadDefaultConfig restores the defaults. SaveConfig always 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

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

    Give it to every player, or swap default for a group such as vip: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.

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.

Prompt: Create an Oxide plugin called PlaytimeTracker that tracks total and per-wipe connected playtime per player in oxide/data, excludes AFK after 300 seconds, and adds /playtime [player] with a lookup permission.
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 tracker

Customize