Skip to content
RustPluginGenerator

How to make a Rust last seen plugin (Oxide /seen command)

Records a timestamp for every player on disconnect and answers /seen <name> with a relative time such as '2 hours ago' or 'online now'. Uses OnPlayerDisconnected, OnPlayerConnected and a data file in oxide/data.

Admins get asked 'when was this player last online' constantly, and Rust itself gives you no way to answer. The only reliable signal is the moment a player leaves the server, because the connection object is still valid inside OnPlayerDisconnected and the player's identity is complete. This plugin stores that moment per user ID in a data file and pairs it with the connected timestamp written by OnPlayerConnected. A /seen chat command then looks up a name or Steam ID against the stored records and prints either 'online now' or a human-readable gap like '3 days ago'. Because lookups are local dictionary reads, the command does not touch the network or the server entity list beyond a single player resolve, and the data file is only written when somebody actually disconnects.

The plugin code

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

namespace Oxide.Plugins
{
    [Info("Last Seen", "RustPluginGenerator", "1.0.0")]
    [Description("Records player disconnect times and answers /seen with a relative last-online gap.")]
    public class LastSeen : RustPlugin
    {
        private const string PermUse = "lastseen.use";

        private PluginConfig config;
        private Dictionary<ulong, PlayerRecord> records = new Dictionary<ulong, PlayerRecord>();

        private class PluginConfig
        {
            [JsonProperty("Show steam id in result")]
            public bool ShowSteamId { get; set; } = true;

            [JsonProperty("Data file name")]
            public string DataFileName { get; set; } = "LastSeen";
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class PlayerRecord
        {
            [JsonProperty("Name")]
            public string Name { get; set; } = "unknown";

            [JsonProperty("LastSeen")]
            public long LastSeen { get; set; }

            [JsonProperty("Connected")]
            public long Connected { get; set; }
        }

        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>
            {
                ["Usage"] = "Usage: /seen <name or steamid>",
                ["NoPermission"] = "You do not have permission to use this command.",
                ["OnlineNow"] = "{0} ({1}) is online now.",
                ["LastSeen"] = "{0} ({1}) was last seen {2}.",
                ["Unknown"] = "No record found for '{0}'.",
                ["Unit.Minutes"] = "{0} minute(s) ago",
                ["Unit.Hours"] = "{0} hour(s) ago",
                ["Unit.Days"] = "{0} day(s) ago",
                ["Unit.Weeks"] = "{0} week(s) ago",
                ["Unit.Months"] = "{0} month(s) ago",
                ["Unit.Long"] = "more than a year ago"
            }, 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);
        }

        private void OnServerInitialized()
        {
            records = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<ulong, PlayerRecord>>(config.DataFileName)
                      ?? new Dictionary<ulong, PlayerRecord>();
        }

        private void Unload()
        {
            SaveRecords();
        }

        private void SaveRecords()
        {
            Interface.Oxide.DataFileSystem.WriteObject(config.DataFileName, records);
        }

        private void OnPlayerConnected(BasePlayer player)
        {
            if (player == null || player.IsNpc) return;

            ulong id = player.userID.Get();
            PlayerRecord record;
            if (!records.TryGetValue(id, out record))
            {
                record = new PlayerRecord();
                records[id] = record;
            }

            record.Name = player.displayName;
            record.Connected = ToUnix(DateTime.UtcNow);
        }

        private void OnPlayerDisconnected(BasePlayer player, string reason)
        {
            if (player == null || player.IsNpc) return;

            ulong id = player.userID.Get();
            PlayerRecord record;
            if (!records.TryGetValue(id, out record))
            {
                record = new PlayerRecord();
                records[id] = record;
            }

            record.Name = player.displayName;
            record.LastSeen = ToUnix(DateTime.UtcNow);
            record.Connected = 0;
            SaveRecords();
        }

        [ChatCommand("seen")]
        private void CmdSeen(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;

            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                SendReply(player, Msg("NoPermission", player.UserIDString));
                return;
            }

            if (args == null || args.Length == 0)
            {
                SendReply(player, Msg("Usage", player.UserIDString));
                return;
            }

            string query = string.Join(" ", args);
            ulong id;
            if (!ulong.TryParse(query, out id))
            {
                BasePlayer target = BasePlayer.FindAwakeOrSleeping(query);
                if (target != null)
                {
                    id = target.userID.Get();
                }
                else
                {
                    id = FindIdByName(query);
                }
            }

            PlayerRecord record;
            if (id == 0 || !records.TryGetValue(id, out record))
            {
                SendReply(player, Msg("Unknown", player.UserIDString, query));
                return;
            }

            BasePlayer live = BasePlayer.FindByID(id);
            bool online = live != null && live.IsConnected && record.Connected > 0;

            string idText = config.ShowSteamId ? id.ToString() : "no id";
            if (online)
            {
                SendReply(player, Msg("OnlineNow", player.UserIDString, record.Name, idText));
                return;
            }

            long now = ToUnix(DateTime.UtcNow);
            long gap = record.LastSeen > 0 ? now - record.LastSeen : 0;
            SendReply(player, Msg("LastSeen", player.UserIDString, record.Name, idText, record.LastSeen > 0 ? FormatGap(gap, player.UserIDString) : Msg("Unit.Long", player.UserIDString)));
        }

        private ulong FindIdByName(string name)
        {
            foreach (KeyValuePair<ulong, PlayerRecord> entry in records)
            {
                if (string.Equals(entry.Value.Name, name, StringComparison.OrdinalIgnoreCase))
                {
                    return entry.Key;
                }
            }
            return 0;
        }

        private static long ToUnix(DateTime time) => (long)(time - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;

        private string FormatGap(long seconds, string userId)
        {
            if (seconds < 0) seconds = 0;
            long minutes = seconds / 60;
            if (minutes < 60) return Msg("Unit.Minutes", userId, Math.Max(1, minutes));
            long hours = minutes / 60;
            if (hours < 24) return Msg("Unit.Hours", userId, hours);
            long days = hours / 24;
            if (days < 7) return Msg("Unit.Days", userId, days);
            long weeks = days / 7;
            if (weeks < 5) return Msg("Unit.Weeks", userId, weeks);
            long months = days / 30;
            if (months < 12) return Msg("Unit.Months", userId, months);
            return Msg("Unit.Long", userId);
        }
    }
}

Hooks used: OnServerInitialized, OnPlayerConnected, OnPlayerDisconnected

Config file

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

LastSeen.json
{
  "Show steam id in result": true,
  "Data file name": "LastSeen"
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/seen <name or steamid>Shows whether the player is online now, or how long ago they were last seen.lastseen.use
Permissions
PermissionGrants
lastseen.useGrants access to the /seen chat command.

How the code works

  1. Recording the disconnect time

    OnPlayerDisconnected(BasePlayer player, string reason) fires while the player object still exists, so player.userID.Get() is stable. The hook also fires when a client times out or is kicked by the server, which is exactly what you want to record. Guard against a null player and an NPC (player.IsNpc), then write DateTime.UtcNow as a Unix timestamp into the record for that user ID and persist the data file immediately.

  2. Marking the online window

    OnPlayerConnected(BasePlayer player) stores the connect timestamp in the same record and keeps the display name, so /seen can show a readable name even if the player renamed themselves since. Keeping both connect and disconnect times means the online check never depends on scanning BasePlayer.activePlayerList for accuracy; the connected timestamp is the marker, and the active player list is only used to confirm the session is still live.

  3. Persisting with DataFileSystem

    Records live in a Dictionary<ulong, PlayerRecord> written to oxide/data/LastSeen through Interface.Oxide.DataFileSystem.WriteObject. The nested PlayerRecord class holds Name, LastSeen and Connected as long Unix seconds and is marked with [JsonObject(MemberSerialization.OptIn)] plus [JsonProperty] on each field so Newtonsoft serializes it predictably. Data is read once in OnServerInitialized and written after every disconnect.

  4. Formatting the gap

    The FormatGap helper turns the difference between now and the stored timestamp into a short phrase: minutes under an hour, hours under a day, days under a week, then weeks and months. It uses DateTime.UtcNow converted to Unix seconds so no timezone or clock drift sneaks in. All phrases come from the language file, with {0} holding the number and {1} the unit, so admins can translate or reword them without touching the plugin.

  5. Resolving the lookup argument

    /seen accepts either a Steam ID or a name. The code first tries ulong.TryParse, then BasePlayer.FindAwakeOrSleeping for the live name, and finally a case-insensitive scan of the stored records' Name fields. When the target is currently connected, the command replies with the 'online now' message instead of a gap. Ambiguous names resolve to the first match, which is acceptable because the Steam ID is always printed alongside.

  6. Permission and language output

    Init registers lastseen.use, and the chat command checks permission.UserHasPermission(player.UserIDString, PermUse) before doing any work, replying with the 'No permission' key from the language dictionary. Every player-facing string, from usage help to the result line, is registered in LoadDefaultMessages and read through the Msg helper so the plugin produces no hard-coded English in chat.

Install it on your server

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

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

Variations

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

  • Verbose player profile

    Prints the last seen gap together with the first-seen date and total recorded sessions.

    Generate this version
  • Inactive player cleanup

    Reports players who have not connected for a configurable number of days.

    Generate this version
  • Wipe-aware records

    Clears or archives the record store when a new map is loaded.

    Generate this version

FAQ

Do I need to keep the plugin loaded for records to be accurate?

Yes. The timestamp is written inside OnPlayerDisconnected, so while the plugin is unloaded nobody is recorded. Players who disconnect during that window keep their previous stamp. Load the plugin from oxide/plugins at startup and leave it there; the data file survives restarts and reloads.

Why does /seen say 'never seen' for a player who is clearly on the server?

The record is only created on connect or disconnect, and lookups fall back to the stored name list. If the name you typed does not match any stored record and is not a live player name, the command reports it as unknown. Use the player's Steam ID, which always resolves once they have connected at least once since the plugin was loaded.

Can players look up each other's last seen time?

Only if they have lastseen.use. Grant it with oxide.grant user <steamid> lastseen.use or to a group. Without the permission the command replies with the 'No permission' message. Admins do not bypass it automatically, so grant the permission to your admin group as well.

How do I install it on Oxide or Carbon?

Drop the .cs file into oxide/plugins, let it compile, then grant lastseen.use where needed. Carbon loads the same file from its plugin folder. The default data file appears at oxide/data/LastSeen after the first disconnect; delete it to reset all records.

Does it handle time zones?

All timestamps are stored as UTC Unix seconds and the gap is computed against DateTime.UtcNow, so server time zone settings and daylight saving have no effect. If you want an absolute date instead of a relative gap, edit the FormatGap helper to return a formatted DateTime in your local zone.

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 called LastSeen with a /seen <name or steamid> command behind the lastseen.use permission that records UTC disconnect times in oxide/data and replies with a relative gap or 'online now'.
Customize for my server

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

Last updated Oct 26, 2026.

Customize last seen command

Customize