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