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
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.
{
"Show steam id in result": true,
"Data file name": "LastSeen"
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/seen <name or steamid> | Shows whether the player is online now, or how long ago they were last seen. | lastseen.use |
| Permission | Grants |
|---|---|
lastseen.use | Grants access to the /seen chat command. |
How the code works
Recording the disconnect time
OnPlayerDisconnected(BasePlayer player, string reason)fires while the player object still exists, soplayer.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 nullplayerand an NPC (player.IsNpc), then writeDateTime.UtcNowas a Unix timestamp into the record for that user ID and persist the data file immediately.Marking the online window
OnPlayerConnected(BasePlayer player)stores the connect timestamp in the same record and keeps the display name, so/seencan show a readable name even if the player renamed themselves since. Keeping both connect and disconnect times means the online check never depends on scanningBasePlayer.activePlayerListfor accuracy; the connected timestamp is the marker, and the active player list is only used to confirm the session is still live.Persisting with DataFileSystem
Records live in a
Dictionary<ulong, PlayerRecord>written tooxide/data/LastSeenthroughInterface.Oxide.DataFileSystem.WriteObject. The nestedPlayerRecordclass holdsName,LastSeenandConnectedaslongUnix seconds and is marked with[JsonObject(MemberSerialization.OptIn)]plus[JsonProperty]on each field so Newtonsoft serializes it predictably. Data is read once inOnServerInitializedand written after every disconnect.Formatting the gap
The
FormatGaphelper 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 usesDateTime.UtcNowconverted 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.Resolving the lookup argument
/seenaccepts either a Steam ID or a name. The code first triesulong.TryParse, thenBasePlayer.FindAwakeOrSleepingfor the live name, and finally a case-insensitive scan of the stored records'Namefields. 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.Permission and language output
Initregisterslastseen.use, and the chat command checkspermission.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 inLoadDefaultMessagesand read through theMsghelper so the plugin produces no hard-coded English in chat.
Install it on your server
Save LastSeen.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/LastSeen.json(Carbon:carbon/configs/LastSeen.json). After editing, reload it:oxide.reload LastSeen c.reload LastSeen # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip: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 versionInactive player cleanup
Reports players who have not connected for a configurable number of days.
Generate this versionWipe-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.
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
- Longest kill shotTracks each player's longest projectile kill distance and the server-wide record, saved to oxide/data, with a /longest chat command and anโฆOnServerInitializedOnPlayerDeath
Last updated Oct 26, 2026.
Customize last seen command
Customize