(has bugs, use at your own risk)
Hey everyone! ![]()
I’ve been programming and scripting in Luau for about 18 months now. Recently, I decided I wanted to build my own StateTracker rather than relying on an existing package. My main goal was to get a deeper understanding of reactive state, generics, and client/server synchronization.
After working on it over the past couple days, I’ve gotten it to a point where I am very happy with it and it fits my use cases perfectly. I wanted to share it with the community for anyone who might find it useful, and to get some feedback!
What is it?
The StateTracker allows you to create strongly typed state dictionaries that can be subscribed to by a specific key. When a value changes on the server, the change is effortlessly replicated to the client, and any connected callbacks instantly react to that change.
Full Transparency
This is designed to be a lightweight state tracker for simple, keyed state. It works beautifully for primitive values (numbers, strings, booleans) and flat objects.
However, I’m still thinking through the edge cases of deep-mutations and heavily nested tables. I’m intentionally keeping it simple for now, and I would love to hear your feedback or suggestions on how to harden it for nested state!
How to Use It
I designed this to be very beginner-friendly. The server acts as the “source of truth”, while the client reads the state and listens for updates.
1. Server Setup (Creating and Updating State)
On the server, you initialize your state with an ID and a default table. From there, you can use built-in methods like :set(), :increment(), or :toggle() to manipulate the data.
local StateTracker = require(path.to.StateTracker)
-- Create a new state tracker with the ID "GameState"
local gameState = StateTracker.server.new("GameState", {
RoundActive = false,
Coins = 0,
Status = "Waiting for players..."
})
-- Easily set new values. This automatically replicates to clients!
gameState:set("Status", "Starting soon!")
-- Toggle booleans without needing to read them first
gameState:toggle("RoundActive")
-- Increment numbers easily
gameState:increment("Coins", 50)
2. Client Setup (Listening for Changes)
On the client, you connect to the same ID. You can then use :subscribe() to fire a function every time a specific value updates, or :changed() if you need to compare the old value to the new one.
local StateTracker = require(path.to.StateTracker)
-- Connect to the existing state tracker
local gameState = StateTracker.client.new("GameState")
-- Run code whenever "Coins" changes
gameState:subscribe("Coins", function(newCoins)
print("Player now has " .. newCoins .. " coins!")
-- Update your UI here!
end)
-- Run code comparing the old and new value
gameState:changed("Status", function(oldStatus, newStatus)
print("Status changed from: " .. oldStatus .. " -> " .. newStatus)
end)
3. Reading State Directly (Server & Client)
Sometimes you don’t need a listener; you just need to check a value exactly when a specific event happens. Both the server and client have access to :get() and :getState().
-- Get a single specific value
local currentCoins = gameState:get("Coins")
print("Current coins:", currentCoins)
-- Get a deep clone of the entire state dictionary
local allState = gameState:getState()
print("All data:", allState)
4. Unsubscribing from Changes
Memory management is important! When you call :subscribe() or :changed(), it returns a cleanup function. Simply call that returned function when you want to stop listening.
-- Save the returned function
local disconnectCoins = gameState:subscribe("Coins", function(newCoins)
print("Coins updated!")
end)
-- Later, when the player dies or a UI closes, stop listening:
disconnectCoins()
5. Resetting and Destroying (Server)
If a round ends or a player leaves, you’ll need ways to reset your data or clean up the tracker entirely. The server has exclusive access to the :reset() and :destroy() methods.
-- Reset a specific key back to its initial starting value
gameState:reset("Coins")
-- Or, reset the ENTIRE state back to the defaults provided in .new()
gameState:reset()
-- If you are completely done with this tracker (e.g., the player left), destroy it.
-- (Note: you can call :destroy() on the client as well to clear client listeners)
gameState:destroy()
6. Silent Updates (dontReplicate)
Sometimes you might want to update a state value on the server without broadcasting that change to all the connected clients. This is useful if you are updating a value multiple times in a single frame and only want to replicate the final result to save network bandwidth, or if a value is purely for the server’s internal logic.
Most server methods (:set(), :increment(), :toggle(), and :reset()) accept an optional dontReplicate boolean parameter at the end.
-- Increment the coins by 10, but DO NOT replicate this to the clients yet
gameState:increment("Coins", 10, true)
-- Toggle a boolean, but keep the change server-only
gameState:toggle("RoundActive", true)
Source Code
Create a ModuleScript named StateTracker. Inside of it, place three more ModuleScripts named StateTrackerClient, StateTrackerServer, and StateTrackerUtils. Paste the code below into their respective modules.
StateTracker (Main Module)
local StateTrackerClient = require(script.StateTrackerClient)
local StateTrackerServer = require(script.StateTrackerServer)
return {
server = StateTrackerServer,
client = StateTrackerClient,
}
StateTrackerServer
--!strict
local RunService = game:GetService("RunService")
local StateTrackerUtils = require(script.Parent.StateTrackerUtils)
local SyncEvent, SyncFunc = StateTrackerUtils.getRemotes()
local StateTrackerServer = {}
StateTrackerServer.__index = StateTrackerServer
export type StateTrackerServer<State> = {
id: string,
_initialState: State,
_state: State,
_listeners: { [string]: { (newValue: any) -> () } },
_changedListeners: { [string]: { (oldValue: any, newValue: any) -> () } },
getState: (self: StateTrackerServer<State>) -> State,
subscribe: <Value>(self: StateTrackerServer<State>, key: string, callback: (newValue: Value) -> ()) -> () -> (),
changed: <Value>(
self: StateTrackerServer<State>,
key: string,
callback: (oldValue: Value, newValue: Value) -> ()
) -> () -> (),
set: <Value>(self: StateTrackerServer<State>, key: string, newValue: Value, dontReplicate: boolean?) -> (),
get: <Value>(self: StateTrackerServer<State>, key: string) -> Value?,
reset: (self: StateTrackerServer<State>, key: string?, dontReplicate: boolean?) -> (),
increment: (self: StateTrackerServer<State>, key: string, amount: number) -> (),
toggle: (self: StateTrackerServer<State>, key: string) -> (),
destroy: (self: StateTrackerServer<State>) -> (),
_fireListeners: <Value>(self: StateTrackerServer<State>, key: string, oldValue: Value, newValue: Value) -> (),
}
local ActiveTrackers = {} :: { [string]: StateTrackerServer<any> }
if RunService:IsServer() then
SyncFunc.OnServerInvoke = function(_: Player, id: string)
local tracker = ActiveTrackers[id]
if tracker then
return tracker:getState()
else
warn("StateTrackerServer: No active tracker found for ID:", id)
return nil
end
end
end
function StateTrackerServer.new<State>(id: string, initialState: State): StateTrackerServer<State>
local self = {
id = id,
_initialState = StateTrackerUtils.deepClone(initialState),
_state = StateTrackerUtils.deepClone(initialState),
_listeners = {},
_changedListeners = {},
}
setmetatable(self, StateTrackerServer)
ActiveTrackers[id] = self :: any
return self :: any
end
function StateTrackerServer._fireListeners<State>(
self: StateTrackerServer<State>,
key: string,
oldValue: State,
newValue: State
)
if self._listeners[key] then
for _, callback in ipairs(self._listeners[key]) do
task.spawn(function()
callback(newValue)
end)
end
end
if self._changedListeners[key] then
for _, callback in ipairs(self._changedListeners[key]) do
task.spawn(function()
callback(oldValue, newValue)
end)
end
end
SyncEvent:FireAllClients(self.id, key, newValue)
end
function StateTrackerServer.set<State, Value>(self: StateTrackerServer<State>, key: string, newValue: Value, dontReplicate: boolean?)
local oldValue = (self._state :: any)[key]
if StateTrackerUtils.deepEqual(oldValue, newValue) then
return
end
if type(oldValue) ~= type(newValue) then
warn(
"Type mismatch when setting state for key: " .. tostring(key),
"Old value type:",
type(oldValue),
"New value type:",
type(newValue)
)
end
(self._state :: any)[key] = newValue
if dontReplicate then
return
end
self:_fireListeners(key, oldValue, newValue)
end
function StateTrackerServer.getState<State>(self: StateTrackerServer<State>): State
return StateTrackerUtils.deepClone(self._state)
end
function StateTrackerServer.get<State, Value>(self: StateTrackerServer<State>, key: string): Value?
return (self._state :: any)[key]
end
function StateTrackerServer.subscribe<State, Value>(
self: StateTrackerServer<State>,
key: string,
callback: (newValue: Value) -> ()
): () -> ()
self._listeners[key] = self._listeners[key] or {}
table.insert(self._listeners[key], callback) --> type cast :: any to callback if needed
return function()
if self._listeners[key] then
for i, cb in ipairs(self._listeners[key]) do
if cb == callback then
table.remove(self._listeners[key], i)
break
end
end
end
end
end
function StateTrackerServer.changed<State, Value>(
self: StateTrackerServer<State>,
key: string,
callback: (oldValue: Value, newValue: Value) -> ()
): () -> ()
self._changedListeners[key] = self._changedListeners[key] or {}
table.insert(self._changedListeners[key], callback)
return function()
if self._changedListeners[key] then
for i, cb in ipairs(self._changedListeners[key]) do
if cb == callback then
table.remove(self._changedListeners[key], i)
break
end
end
end
end
end
function StateTrackerServer.increment<State>(
self: StateTrackerServer<State>,
key: string,
amount: number,
dontReplicate: boolean?
)
local currentValue = (self._state :: any)[key]
if type(currentValue) ~= "number" then
warn(
"Attempted to increment a state that is not a number: " .. tostring(key),
"Current value:",
tostring(currentValue)
)
return
end
self:set(key, currentValue + amount, dontReplicate)
end
function StateTrackerServer.toggle<State>(self: StateTrackerServer<State>, key: string, dontReplicate: boolean?)
local currentValue = (self._state :: any)[key]
if type(currentValue) ~= "boolean" then
warn(
"Attempted to toggle a state that is not a boolean: " .. tostring(key),
"Current value:",
tostring(currentValue)
)
return
end
self:set(key, not currentValue, dontReplicate)
end
function StateTrackerServer.reset<State>(self: StateTrackerServer<State>, key: string?, dontReplicate: boolean?)
if key then
self:set(key, StateTrackerUtils.deepClone((self._initialState :: any)[key]), dontReplicate)
return
end
for k, _ in pairs(self._state) do
if (self._initialState :: any)[k] == nil then
(self._state :: any)[k] = nil
end
end
for k, v in pairs(self._initialState) do
self:set(k :: any, StateTrackerUtils.deepClone(v :: any), dontReplicate)
end
end
function StateTrackerServer.destroy<State>(self: StateTrackerServer<State>)
table.clear(self._listeners)
table.clear(self._changedListeners)
ActiveTrackers[self.id] = nil
self._state = {} :: any
end
return StateTrackerServer
StateTrackerClient
--!strict
local RunService = game:GetService("RunService")
local StateTrackerUtils = require(script.Parent.StateTrackerUtils)
local StateTrackerClient = {}
StateTrackerClient.__index = StateTrackerClient
export type StateTrackerClient<State> = {
id: string,
_state: State,
_listeners: { [string]: { (oldValue: any, newValue: any) -> () } },
_changedListeners: { [string]: { (oldValue: any, newValue: any) -> () } },
getState: (self: StateTrackerClient<State>) -> State,
subscribe: <Value>(
self: StateTrackerClient<State>,
key: string,
callback: (newValue: Value) -> ()
) -> () -> (),
get: <Value>(self: StateTrackerClient<State>, key: string) -> Value,
changed: <Value>(
self: StateTrackerClient<State>,
key: string,
callback: (oldValue: Value, newValue: Value) -> ()
) -> () -> (),
_networkUpdate: <Value>(self: StateTrackerClient<State>, key: string, newValue: Value) -> (),
}
local SyncEvent, SyncFunc = StateTrackerUtils.getRemotes()
local ActiveTrackers = {} :: { [string]: StateTrackerClient<any> }
if RunService:IsClient() then
SyncEvent.OnClientEvent:Connect(function(id: string, key: string, newValue)
if ActiveTrackers[id] then
ActiveTrackers[id]:_networkUpdate(key, newValue)
end
end)
end
function StateTrackerClient.new<State>(id: string): StateTrackerClient<State>
local self = {
id = id,
_state = {},
_listeners = {},
_changedListeners = {},
}
local initialState = SyncFunc:InvokeServer(id)
if initialState == nil then
warn("StateTrackerClient: tracker not ready yet for id:", id)
return nil :: any
end
self._state = StateTrackerUtils.deepClone(initialState)
setmetatable(self, StateTrackerClient)
ActiveTrackers[id] = self :: any
return self :: any
end
function StateTrackerClient.getState<State>(self: StateTrackerClient<State>): State
return StateTrackerUtils.deepClone(self._state)
end
function StateTrackerClient.get<State, Value>(self: StateTrackerClient<State>, key: string): Value
return (self._state :: any)[key]
end
function StateTrackerClient.subscribe<State, Value>(
self: StateTrackerClient<State>,
key: string,
callback: (newValue: Value) -> ()
): () -> ()
self._listeners[key] = self._listeners[key] or {}
table.insert(self._listeners[key], callback :: any)
return function()
if self._listeners[key] then
for i, cb in ipairs(self._listeners[key]) do
if cb == callback :: any then
table.remove(self._listeners[key], i)
break
end
end
end
end
end
function StateTrackerClient.changed<State, Value>(
self: StateTrackerClient<State>,
key: string,
callback: (oldValue: Value, newValue: Value) -> ()
): () -> ()
self._changedListeners[key] = self._changedListeners[key] or {}
table.insert(self._changedListeners[key], callback :: any)
return function()
if self._changedListeners[key] then
for i, cb in ipairs(self._changedListeners[key]) do
if cb == callback :: any then
table.remove(self._changedListeners[key], i)
break
end
end
end
end
end
function StateTrackerClient._networkUpdate<State>(self: StateTrackerClient<State>, key: string, newValue: any)
local oldValue = (self._state :: any)[key]
if oldValue == newValue then
return
end
(self._state :: any)[key] = newValue
if self._listeners[key] then
for _, callback in ipairs(self._listeners[key]) do
task.spawn(function()
callback(newValue)
end)
end
end
if self._changedListeners[key] then
for _, callback in ipairs(self._changedListeners[key]) do
task.spawn(function()
callback(oldValue, newValue)
end)
end
end
end
function StateTrackerClient.destroy<State>(self: StateTrackerClient<State>)
table.clear(self._listeners)
table.clear(self._changedListeners)
ActiveTrackers[self.id] = nil
self._state = {} :: any
end
return StateTrackerClient
StateTrackerUtils
local RunService = game:GetService("RunService")
local REMOTE_FOLDER_NAME = "_remotes"
local REMOTE_EVENT_NAME = "SyncEvent"
local REMOTE_FUNCTION_NAME = "SyncFunc"
local remoteFolder
local remoteEvent
local remoteFunction
if RunService:IsServer() then
remoteFolder = Instance.new("Folder")
remoteFolder.Name = REMOTE_FOLDER_NAME
remoteFolder.Parent = script
remoteEvent = Instance.new("RemoteEvent")
remoteEvent.Name = REMOTE_EVENT_NAME
remoteEvent.Parent = remoteFolder
remoteFunction = Instance.new("RemoteFunction")
remoteFunction.Name = REMOTE_FUNCTION_NAME
remoteFunction.Parent = remoteFolder
else
remoteFolder = script:WaitForChild(REMOTE_FOLDER_NAME, 5)
remoteEvent = remoteFolder:WaitForChild(REMOTE_EVENT_NAME, 5)
remoteFunction = remoteFolder:WaitForChild(REMOTE_FUNCTION_NAME, 5)
end
local StateTrackerUtils = {}
function StateTrackerUtils.getRemotes(): (RemoteEvent, RemoteFunction)
return remoteEvent, remoteFunction
end
function StateTrackerUtils.deepClone(value)
if typeof(value) ~= "table" then
return value
end
local out = {}
for k, v in pairs(value) do
out[k] = StateTrackerUtils.deepClone(v :: any)
end
return out
end
function StateTrackerUtils.deepEqual(a, b)
if a == b then
return true
end
local aType = typeof(a)
local bType = typeof(b)
if aType ~= "table" or bType ~= "table" then
return false
end
for k, v in pairs(a) do
if not StateTrackerUtils.deepEqual(v :: any, b[k]) then
return false
end
end
for k, _ in pairs(b) do
if a[k] == nil then
return false
end
end
return true
end
return StateTrackerUtils