Lightweight Player Telemetry & Server Mood System
Hey everyone ![]()
I wanted to share a small player lifecycle / telemetry system I’ve been experimenting with. It’s designed to work without relying on PlayerAdded / PlayerRemoving, and instead builds a consistent view of the server purely from periodic scans.
This started as a simple join/leave tracker and slowly evolved into a runtime analytics layer that tracks concurrency, session duration, churn, and even a high-level “server mood.”
What this system tracks
Core stats
- Current concurrent players
- Peak concurrent players (server lifetime)
- Total completed sessions
- Average session length
- Oldest active session
Activity tracking
- Join timestamps
- Leave timestamps
Derived state
- Server Mood (based on recent activity + session behavior)
Server Mood
The system derives a qualitative server state using recent join/leave activity:
| Mood | Meaning |
|---|---|
Empty |
No players |
Chilling |
Stable population, no recent churn |
Stable |
Normal activity |
Hyped |
Rapid joins |
Offended |
Rapid leaves |
Chaotic |
High join/leave activity |
Touristy |
Very short average sessions |
This is intentionally high-level and heuristic-based. The goal is not precision, but readable runtime insight that can be surfaced in logs, attributes, or diagnostics.
Why polling instead of events?
This system intentionally avoids Players.PlayerAdded / PlayerRemoving to explore:
- Server-side consistency under unusual edge cases
- Building derived state from observed reality, not event order
- Runtime analytics that don’t depend on signal delivery
- A single loop that can later be throttled, sampled, or adapted
This is not meant to replace events in normal gameplay code — it’s more of a monitoring / telemetry layer.
Example use cases
- Debugging server churn
- Detecting unstable play sessions
- Logging server “health” over time
- Feeding live diagnostics to an admin UI
- Stress-testing join/leave behavior
Full Script
--!nolint
--!nocheck
--!strict
--!optimize 2
--!native
local Players: typeof(game.Players) = game.Players
local CachedPlayers: {[typeof('string')]: typeof(0)} = {}
local JoinTimestamps: {typeof(0)} = {}
local LeaveTimestamps: {typeof(0)} = {}
local LastMood: typeof('string') = 'Empty'
local LastMoodChangeAt: typeof(0) = os.clock()
local GameStats: {
Concurrent: typeof(0),
PeakConcurrent: typeof(0),
TotalSessions: typeof(0),
AverageSessionTime: typeof(0),
OldestPlayer: typeof('string'),
Mood: typeof('string')
} = {
Concurrent = 0,
PeakConcurrent = 0,
TotalSessions = 0,
AverageSessionTime = 0,
OldestPlayer = '',
Mood = 'Empty'
}
local function CountInWindow(timestamps: {typeof(0)}, windowSeconds: typeof(0)): typeof(0)
while #timestamps > 0 and timestamps[1] < os.clock() - windowSeconds do
table.remove(timestamps, 1)
end
return #timestamps
end
local function ComputeMood(): typeof('string')
if GameStats.Concurrent <= 0 then
return "Empty"
end
local joins10: typeof(0) = CountInWindow(JoinTimestamps, 10)
local leaves10: typeof(0) = CountInWindow(LeaveTimestamps, 10)
local activity10: typeof(0) = joins10 + leaves10
local joins60: typeof(0) = CountInWindow(JoinTimestamps, 60)
local leaves60: typeof(0) = CountInWindow(LeaveTimestamps, 60)
local activity60: typeof(0) = joins60 + leaves60
if activity60 == 0 then
return "Chilling"
end
if leaves10 >= 6 and leaves10 > joins10 * 2 then
return "Offended"
end
if joins10 >= 6 and joins10 > leaves10 * 2 then
return "Hyped"
end
if activity10 >= 10 then
return "Chaotic"
end
if GameStats.AverageSessionTime > 0 and GameStats.AverageSessionTime < 45 then
return "Touristy"
end
return "Stable"
end
local function OnJoin(player: typeof(game.Players:FindFirstChildOfClass('Player')))
print(`{player.Name} has joined the game`)
table.insert(JoinTimestamps, os.clock())
local UserId: typeof('string') = tostring(player.UserId)
CachedPlayers[UserId] = os.clock()
end
local function OnLeave(player: typeof(game.Players:FindFirstChildOfClass('Player')))
print(`{player.Name} has left the game`)
table.insert(LeaveTimestamps, os.clock())
GameStats.TotalSessions = GameStats.TotalSessions + 1
local UserId: typeof('string') = tostring(player.UserId)
CachedPlayers[UserId] = nil
if CachedPlayers[GameStats.OldestPlayer] then return end
local oldest: typeof('string')
for id, join in CachedPlayers do
if not CachedPlayers[oldest] or CachedPlayers[oldest] > join then
oldest = id
end
end
end
local function Update(concurrent: typeof(0), average: typeof(0))
GameStats.Concurrent = concurrent
GameStats.AverageSessionTime = average / GameStats.Concurrent
if GameStats.Concurrent > GameStats.PeakConcurrent then
GameStats.PeakConcurrent = GameStats.Concurrent
end
GameStats.Mood = ComputeMood()
if GameStats.Mood ~= LastMood then
LastMood = GameStats.Mood
LastMoodChangeAt = os.clock()
end
end
while task.wait() do
for _, player in game.Players:GetChildren() do
local UserId: typeof('string') = tostring((player :: typeof(game.Players:FindFirstChildOfClass('Player'))).UserId)
if not CachedPlayers[UserId] then
OnJoin(player :: typeof(game.Players:FindFirstChildOfClass('Player')))
end
end
local concurrent: typeof(0) = 0
local average: typeof(0) = 0
for id, session in CachedPlayers do
local UserId = tonumber(id) :: typeof(0)
local player = game.Players:GetPlayerByUserId(UserId) :: typeof(game.Players:FindFirstChildOfClass('Player'))
if not game.Players:FindFirstChild(player.Name) then
OnLeave(player :: typeof(game.Players:FindFirstChildOfClass('Player')))
end
average = average + (os.clock() - session)
concurrent = concurrent + 1
end
Update(concurrent, average)
end