Simple Typed State Controller

Simple Type-Safe State Controller

Hello everyone!

I have been working on a small State Controller module for Roblox that allows me to create and manage states from both the Server and Client, while also providing Luau generic type support.

The main goal of this module is to keep state management simple, type-safe, and easy to use.

Features

  • Create states from the Server or Client
  • Generic type support with Luau
  • Get and set a player’s state
  • Set a state for all players
  • Get players matching a specific state
  • Listen for state changes
  • Optionally call the listener immediately with the current state
  • Initialize player states through Player_Init
  • Uses Roblox Attributes as the underlying state storage

Generic Type Support

For example, I can define a state that only accepts specific values:

export type Entries = "Enabled" | "Disabled"

local Custom_Layout: Entries = "Disabled"

local State = State_Service.New("UI_Layout", Custom_Layout)

State.Run_Side = "Client"

Because State_Service.New uses generics, the type of the state is inferred from the value passed to it.

This allows the state to remain strongly typed when using the module.

Client Example

Here is an example of how I currently use it on the Client:

export type Entries = "Enabled" | "Disabled"

local Custom_Layout: Entries = "Disabled"

local State = State_Service.New("UI_Layout", Custom_Layout)
State.Run_Side = "Client"

State:Player_Init(function(Player: Player)
	if RunService:IsServer() then
		return
	end

	if Player ~= Players.LocalPlayer then
		return
	end

	State:Set(Player, "Disabled")
end)

return State

The same State Controller can also be created on the Server.

Server Example

For example, a state that controls whether a player is currently in combat:

export type Entries = "Combat" | "Safe"

local Custom_State: Entries = "Safe"

local State = State_Service.New("Combat_State", Custom_State)
State.Run_Side = "Server"

State:Player_Init(function(Player: Player)
	return "Safe"
end)

return State

Since this State is running on the Server, the Server can manage the state of individual players:

State:Set(Player, "Combat")
State:Set(Player, "Safe")

It can also update every player’s state:

State:SetAll("Safe")

And retrieve players that currently have a specific state:

local CombatPlayers = State:GetPlayers("Combat")

Listening to State Changes

Both Client and Server states can listen for changes:

State:Listen(false, Player, function(Current_State)
	print("State changed:", Current_State)
end)

The callback receives the state with the generic type.

For example, with:

export type Entries = "Enabled" | "Disabled"

the callback’s state is typed as:

"Enabled" | "Disabled"

Why I Made It

I wanted to avoid creating a separate state-management implementation for every system.

For example, instead of repeatedly writing custom code for things like:

Player:SetAttribute("UI_Layout", "Enabled")

and manually creating listeners for every state, I can create a reusable State object:

local State = State_Service.New("UI_Layout", "Disabled")

Then I can use:

State:Get(Player)
State:Set(Player, "Enabled")
State:Equals(Player, "Enabled")
State:GetPlayers("Enabled")
State:Listen(...)

This also gives me a centralized API for managing states.

I am mainly interested in feedback regarding the API design, generic typing, Client/Server architecture, and whether there are any improvements I could make while keeping the system simple.

I’d especially like to hear opinions from people who have built their own state-management systems in Luau.

Github repository

Source Code

-- AUTHOR       : TheEnesDev 
-- DATE (D/M/Y) : 17/09/2026 

-- ──────────────────────── SERVICES 
local RunService = game:GetService("RunService") 
local Players = game:GetService("Players") 
local ServerStorage = game:GetService("ServerStorage") 
local ServerScriptService = game:GetService("ServerScriptService") 
local ReplicatedStorage = game:GetService("ReplicatedStorage") 

-- ──────────────────────── TYPES 
export type State<T> = { 
	State_UID : string, 
	Run_Side : "Client" | "Server", 
	Config : { 
		Custom_State : T, 
	}, 

	Get : (self:State<T>,Player:Player) -> T, 
	GetPlayers : (self:State<T>) -> {Players}, 

	Equals : (self:State<T>,Player:Player,State:T) -> boolean, 

	Set : (self:State<T>,Player:Player,State:T) -> nil, 
	SetAll : (self:State<T>,State:T) -> nil, 

	Player_Init : (self:State<T>,(Player:Player) -> T) -> RBXScriptConnection, 
	Listen : (self:State<T>,CallNow:boolean?,Player:Player,CallBack:(State:T) -> ()) -> RBXScriptConnection, 
} 

-- ──────────────────────── HELPERS 
const function IsPlayer(Player:Player) 
	return Player and typeof(Player) == "Instance" and Player:IsA("Player") 
end 

-- ──────────────────────── SERVICE 
local Service = {} 
local State = {} 
State.__index = State 

function State:Get(Player:Player) 
	local Player = Player or (RunService:IsClient() and Players.LocalPlayer) 
	assert(self and self.State_UID,"[STATE] invalid class.") 
	assert(IsPlayer(Player),"[STATE] invalid player.") 

	return Player:GetAttribute(self.State_UID) 
end 

function State:Equals(Player:Player,State:any) : boolean 
	local Player = Player or (RunService:IsClient() and Players.LocalPlayer) 
	assert(self and self.State_UID,"[STATE] invalid class.") 
	assert(IsPlayer(Player),"[STATE] invalid player.") 

	return self:Get(Player) == State 
end 

function State:GetPlayers(State:any) 
	assert(self and self.State_UID,"[STATE] invalid class.") 

	local WhiteList = {} 
	for _,Player:Player in Players:GetPlayers() do 
		if self:Get(Player) ~= State then continue end 
		table.insert(WhiteList,Player) 
	end 

	return WhiteList 
end 

function State:Set(Player:Player,State:any) 
	assert(self and self.State_UID,"[STATE] invalid class.") 

	local Player = (self.Run_Side == "Server") and Player or (RunService:IsClient() and Players.LocalPlayer) 
	assert(IsPlayer(Player),"[STATE] invalid player.") 

	Player:SetAttribute(self.State_UID,State) 
end 

function State:SetAll(State:any) 
	if not RunService:IsServer() then return end 
	assert(self and self.Config,"[STATE] invalid class.") 

	self.Config.Custom_State = State 
	for _,Player:Player in Players:GetPlayers() do 
		self:Set(Player,State) 
	end 
end 

function State:Player_Init(CallBack:(Player:Player) -> any) 
	assert(self and self.State_UID,"[STATE] invalid class.") 
	assert(CallBack and typeof(CallBack) == "function","[STATE] invalid callback.") 

	for _,Player in Players:GetPlayers() do 
		task.spawn(function() 
			local Result = CallBack(Player) 
			if self.Run_Side == "Client" then return end 
			self:Set(Player,Result) 
		end) 
	end 

	return Players.PlayerAdded:Connect(function(Player) 
		local Result = CallBack(Player) 
		if self.Run_Side == "Client" then return end 
		self:Set(Player,Result) 
	end) 
end 

function State:Listen(CallNow:boolean,Player:Player,CallBack:(State:any) -> ()) 
	local Player = Player or (RunService:IsClient() and Players.LocalPlayer) 
	assert(self and self.State_UID,"[STATE] invalid class.") 
	assert(IsPlayer(Player),"[STATE] invalid player.") 
	assert(CallBack and typeof(CallBack) == "function","[STATE] invalid callback.") 

	if CallNow then 
		task.spawn(CallBack,self:Get(Player)) 
	end 

	return Player:GetAttributeChangedSignal(self.State_UID):Connect(function() 
		task.spawn(CallBack,self:Get(Player)) 
	end) 
end 

function Service.New<T>(name:string,value:T): State<T> 
	assert(name and typeof(name) == "string","[STATE] invalid state name.") 
	local NewState:State<T> = setmetatable({},State) 
	
	NewState.Run_Side = "Server"
	NewState.State_UID = name 
	NewState.Config = { 
		Custom_State = value, 
	} 

	return NewState 
end 

return Service
3 Likes

cant this be easily replicated with attributes on player instances? query descendants exists too btw if you need to query players based on their attributes and whatnot

Attributes can definitely be used for simple states on Player instances, and I agree that they are useful for that kind of use case. However, the purpose of this system is not simply to store a value on a Player.

The main difference is that this system provides a dedicated, typed state abstraction. States can be created and managed independently from the Player instance, while still being accessible from both the client and server through the same API. It also supports generics, so the type of the state value can be preserved and checked by Luau’s type system.

Another important part is the listener system. Instead of manually checking an Attribute or setting up AttributeChanged signals for every state, you can directly listen to a state and optionally receive its current value immediately. This also makes the state logic more centralized and reusable.

QueryDescendants is useful for finding instances based on attributes or other properties, but querying instances and managing state are two different problems. Querying can tell you which Players match a condition; it doesn’t provide the typed state abstraction, state lifecycle, listeners, or the client/server API that this system provides.

So yes, Attributes can replicate part of the functionality for simpler use cases. The goal here isn’t to replace Attributes, but to provide a more structured API for cases where I want state to be treated as an actual system rather than just a value stored on a Player.

1 Like