Lightweight Player Telemetry & Server Mood System

:brain: Lightweight Player Telemetry & Server Mood System

Hey everyone :waving_hand:
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.”


:sparkles: 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)

:performing_arts: 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.


:magnifying_glass_tilted_left: 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.


:puzzle_piece: 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

:scroll: 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

:speech_balloon: Feedback welcome

4 Likes

Especially useful for verified users like myself, with huge games and not enough data tracking. Another banger script from Hank_Hail. The next hack wizard after berezza.

2 Likes

Super amazing super cool freaking tough system by Hank! Unfortunately for me the script would reject itself in my games because I get no players and it would get sad. I am making this post so Hank will let me see the sun again for the first time in many months. Once again Hank, you proved yourself worthy of god level scripting tier!

1 Like

10/10 my game started raining players

1 Like