StateSystem Design Review

Hello!

I am working on a horror game with a friend of mine as the scripter and programmer and wanted feedback on the StateSystem I created. I am new to development and was looking if there is any way I can improve this or should I redesign it. Currently it’s in an infancy stage of development since this is a new project but is the most complex working system I have created so far.

Here are all the scripts that go to the system as well as any data that could help.

StateManager: located in ServerScriptService that acts as a logic and redirects

local PlayerState = {}

-- Services
local players = game:GetService("Players")
local replicatedstorage = game:GetService("ReplicatedStorage")
local serverstorage = game:GetService("ServerStorage")
local runservice = game:GetService("RunService")

local packet = require(replicatedstorage:WaitForChild("Packet"))
local GetService = require(replicatedstorage:WaitForChild("Services"):WaitForChild("GetService"))

-- Folders
local shared = replicatedstorage:WaitForChild("Shared")
local assets = replicatedstorage:WaitForChild("Assets")
local Services = replicatedstorage:WaitForChild("Services")

local StateValues = shared:WaitForChild("StateValues")

-- Modules
local StateResolver = require(script.StateResolver)
local StateDictionary = require(StateValues:WaitForChild("StateDictionary"))
local ActionDictionary = require(StateValues:WaitForChild("ActionDicitonary"))

-- Remotes
local ActionRequest = packet("Request", packet.Any, packet.Any)
local ActionExecute = packet("Execute", packet.String, packet.Any, packet.Any, packet.Any)

local DeveloperState = packet("DeveloperState", packet.Any)

-- Actions
local actions = assets:WaitForChild("Actions")

local PlayerSprint = require(actions:WaitForChild("PlayerSprint"))

-- Extras

--// Initialize //--

-- adds player to the state table
players.PlayerAdded:Connect(function(player)
	if PlayerState[player] == nil then
		PlayerState[player] = {}
		PlayerState[player].Actions = {}
		PlayerState[player].HighestPriority = 0 -- means no current state or idle
		PlayerState[player].Cutscene = false
		PlayerState[player].CurrentCamera = 0
	end
	
	local character = player.Character or player.CharacterAdded:Wait()
	local head = character:WaitForChild("Head")
	local faceControls = head:WaitForChild("FaceControls", 5)
	if faceControls then
		faceControls:Destroy()
	end
end)

-- removes player from the state table, saves memory
players.PlayerRemoving:Connect(function(player)
	if PlayerState[player] then
		PlayerState[player] = nil
	end
end)

--[[ extra info
CurrentCamera - im gonna forget this so here
1 = free/normal camera
2 = thrid person mouse lock (just like a shift lock)
3 = first person
10 = unlocked mouse (locks camera and all movement)
]]

-- checks the action for either "OnExit" or "OnEnter" depending on key
local function CheckAction(player, state, key)
	if state == nil then error("action does not exist") return end
	
	if key == "InputBegan" then
		if state["OnEnter"] then
			state.OnEnter(player, player.Character, player.Character.Humanoid)
			
			PlayerState[player].Actions[state["Name"]] = state
			PlayerState[player].HighestPriority = state.Priority
			
			return true
		else
			return false
		end
	elseif key == "InputEnded" then
		if state["OnExit"] then
			state.OnExit(player, player.Character, player.Character.Humanoid)

			-- Remove the state from Actions and reset priority
			PlayerState[player].Actions[state["Name"]] = nil
			PlayerState[player].HighestPriority = 0

			return true
		else
			return false
		end
	else
		print(key)
		error("invaild check given")
	end
end

--// Functions //--

-- updates player values in PlayerState table
local function StateUpdate(player : Player, action, key : string, active : string)
	local PlayerUpdate = PlayerState[player]
	
	if key == "dev" then -- dev state update
		for name, data in pairs(PlayerState[player].Actions) do
			-- ends state
			CheckAction(player, data, "InputEnded")
			
			PlayerState[player].Actions[name] = nil
		end
		
		-- updates highest priority to turn on and off dev state
		if PlayerUpdate.HighestPriority == 0 then
			PlayerUpdate.HighestPriority = 20
		else
			PlayerUpdate.HighestPriority = 0
		end
		
		return
	elseif key == "update" then -- normal state update
		for name, data in pairs(PlayerState[player].Actions) do
			-- executes any code when the state is ended
			CheckAction(player, data, "InputEnded")
			
			PlayerState[player].Actions[name] = nil
		end
		
		return
	end
end

-- checks request, resolves request, and executes request
local function NewState(player, character, humanoid, input : any, active : any)
	local state = ActionDictionary[input.Name]
	
	local action = StateDictionary.States[state]
	local movementAction = ActionDictionary.Movement[input.Name]
		
	-- these check stuff and end the script if needed
	if movementAction ~= nil then return end
	if action == nil then warn("state does not exist") return end
	
	-- Handle exiting states differently - don't check priority resolver
	if active == "InputEnded" then
		-- Only end the state if it's actually active
		if PlayerState[player].Actions[action["Name"]] then
			CheckAction(player, action, "InputEnded")
		end
		
		return action
	end
	
	-- For entering new states, check priority
	if StateResolver.ResolveRequest(action, PlayerState[player]) == "Fail" then return end
	
	StateUpdate(player, action, "update", active)
	CheckAction(player, action, active)
	
	return action
end

--// Developer Tools //--

-- allows developer to change highestpriority state, allows testing priority and state saving via list
function DeveloperState(player : Player, active : any)
	if active == "InputEnded" then return end -- makes this toggle
	
	StateUpdate(player, nil, true) -- sets highest priority to either 20 or 0 but the key is what allows that
end

--// Main //--

-- basically just uis.inputbegan but adds in packet for client communication
ActionRequest.OnServerEvent:Connect(function(player : Player, input : any, active : any)
	local character = player.Character
	local humanoid = character:WaitForChild("Humanoid")
	
	-- redirects depending on key and action
	if input == Enum.KeyCode.B then -- camera change
		if active == "InputEnded" then return end
		
		if PlayerState[player].CurrentCamera ~= 2 then
			PlayerState[player].CurrentCamera += 1
		else
			PlayerState[player].CurrentCamera = 0
		end
		
		ActionExecute:FireClient(player, "MouseLock", PlayerState[player].CurrentCamera, active)
	elseif input == Enum.KeyCode.X then -- dev state for testing priority and ending actions
		DeveloperState(player, active) 
	elseif input == Enum.KeyCode.Z then -- prints entire playerstate for player
		if active == "InputEnded" then return end
		print(PlayerState[player])
	else -- if it gets here it will attempt a real action like sprint or crouch
		local action = NewState(player, character, humanoid, input, active)
		
		if action == nil then return end
		
		if ActionExecute.Id == nil then -- checks if packet exists on server-side
			error("Packet does not exist on server side")
		else
			local TotalActions = GetService:GetTableLen(PlayerState[player].Actions)
			
			ActionExecute:FireClient(player, "Update", TotalActions, action["Name"], active)
		end
	end
end)

Also here is my StateResolver that the script above calls for, just a module located in the script

local StateResolver = {}

-- Services
local replicatedstorage = game:GetService("ReplicatedStorage")

-- Folders
local shared = replicatedstorage:WaitForChild("Shared")

-- Modules
local StateDictionary = require(shared:WaitForChild("StateValues"):WaitForChild("StateDictionary"))

--// Functions //--

function StateResolver.ResolveRequest(action, PlayerState)
	if PlayerState == nil then error("did not get player state") return "Failure" end
	
	local activeStates = PlayerState.Actions
	if activeStates and activeStates[action["Name"]] == nil then
		if action.Priority > PlayerState.HighestPriority then 
			return "Success" 
		else
			return "Fail"
		end
	else
		return "Success"
	end
end

return StateResolver

ActionExecute: located in StarterPlayerScripts, it acts as a server-to-client executor for anything the server can’t do

-- Services
local players = game:GetService("Players")
local replicatedstorage = game:GetService("ReplicatedStorage")
local runservice = game:GetService("RunService")

local player = players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")

local packet = require(replicatedstorage:WaitForChild("Packet"))

-- Folders
local Assets = replicatedstorage:WaitForChild("Assets")
local Shared = replicatedstorage:WaitForChild("Shared")

local StateValues = Shared:WaitForChild("StateValues")

-- Modules
local AnimationHandler = require(character:WaitForChild("AnimationHandler"))
local CameraHandler = require(character:WaitForChild("CameraHandler"))

local StateDictionary = require(StateValues:WaitForChild("StateDictionary"))
local ActionDictionary = require(StateValues:WaitForChild("ActionDicitonary"))

-- Remotes
local ActionExecute = packet("Execute", packet.String, packet.Any, packet.Any, packet.Any)
local SettingsCall = packet("SettingsUpdate", packet.Any, packet.Any, packet.Any, packet.Any)

--// Execute //--

ActionExecute.OnClientEvent:Connect(function(call, CameraInfo, state, Active)
	if call == "MouseLock" then
		CameraHandler.LockMouse(player, character, CameraInfo, Active)
	elseif call == "Update" then
		local action = StateDictionary.States[state]
		
		CameraHandler.Update(player, action, CameraInfo, Active)
	end
end)

ClientHandler: located in StarterCharacterScripts, it acts as another redirect for input and other information only the client can get

-- Services
local uis = game:GetService("UserInputService")
local replicatedstorage = game:GetService("ReplicatedStorage")
local players = game:GetService("Players")
local runservice = game:GetService("RunService")

local packet = require(replicatedstorage:WaitForChild("Packet"))

-- Folders
local Assets = replicatedstorage:WaitForChild("Assets")
local Actions = Assets:WaitForChild("Actions")
local Animations = Assets:WaitForChild("Animations")

-- Modules
local AnimationHandler = require(script.Parent:WaitForChild("AnimationHandler"))

local AnimationIndex = require(Animations:WaitForChild("AnimationIndex"))

-- Remotes
local ActionRequest = packet("Request", packet.Any, packet.Any)
local DeveloperState = packet("DeveloperState", packet.Any)
local AnimationExecute = packet("ActionExecute", packet.Any)

--// Redirects //--

-- redirects the animation to the AnimationHandler from server state change
AnimationExecute.OnClientEvent:Connect(function(action)
	--local animation = AnimationIndex[action]
	
	--AnimationHandler.Execute[action](animation)
end)

--// Functions //--

-- fires server (thank you @guestw3334)
local function SendInput(input, active)
	if input.UserInputType == Enum.UserInputType.Keyboard then
		ActionRequest:Fire(input.KeyCode, active)
	end
end

--// Inputs //--

-- reads for input began
uis.InputBegan:Connect(function(input)
	if ActionRequest.Id == nil then -- checks if packet exists on server-side
		error("Packet does not exist on server side")
	else -- if exists then fire
		SendInput(input, "InputBegan")
	end
end)

-- reads for input ended
uis.InputEnded:Connect(function(input)
	if ActionRequest.Id == nil then -- checks if packet exists on server-side
		error("Packet does not exist on server side")
	else -- if exists then fire
		SendInput(input, "InputEnded")
	end
end)

CameraHandler: located in StarterCharacterScripts, it acts as a service module for the client act on the camera based on the input from the server or user

local CameraHandler = {}

-- Services
local players = game:GetService("Players")
local replicatedstorage = game:GetService("ReplicatedStorage")
local runservice = game:GetService("RunService")
local uis = game:GetService("UserInputService")
local tween = game:GetService("TweenService")

local camera = workspace.CurrentCamera
local player = players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local root = character:WaitForChild("HumanoidRootPart")
local humanoid = character:WaitForChild("Humanoid")

-- Folders
local Assets = replicatedstorage:WaitForChild("Assets")
local Shared = replicatedstorage:WaitForChild("Shared")

local StateValues = Shared:WaitForChild("StateValues")
local Configs = Shared:WaitForChild("Configs")

-- Modules
local ActionDictionary = require(StateValues:WaitForChild("ActionDicitonary"))
local StateDictionary = require(StateValues:WaitForChild("StateDictionary"))

local DefaultConfig = require(Configs:WaitForChild("DefaultConfig"))

-- Variables

local Previous = 0

local Enums = {
	
	["0"] = {
		["MouseBehavior"] = Enum.MouseBehavior.Default,
		["CameraMode"] = Enum.CameraMode.Classic,
		["AutoRotate"] = true,
		["Subject"] = "Humanoid"
	},
	
	["1"] = {
		["MouseBehavior"] = Enum.MouseBehavior.LockCenter,
		["CameraMode"] = Enum.CameraMode.Classic,
		["AutoRotate"] = true,
		["Subject"] = "Humanoid"
	},
	
	["2"] = {
		["MouseBehavior"] = Enum.MouseBehavior.LockCenter,
		["CameraMode"] = Enum.CameraMode.LockFirstPerson,
		["AutoRotate"] = true,
		["Subject"] = "Head",
		
		OnEnter = function()
			for _, v in pairs(character:GetChildren()) do
				if v:IsA("BasePart") then
					v.LocalTransparencyModifier = 0.7
				elseif v:IsA("Accessory") then
					v.Handle.Transparency = 1
				elseif v.Name == "Head" then
					local face = v.face
					face.Transparency = 1
				end
			end
		end,
		OnExit = function()
			for _, v in pairs(character:GetChildren()) do
				if v:IsA("BasePart") then
					v.LocalTransparencyModifier = 0
				elseif v:IsA("Accessory") then
					v.Handle.Transparency = 0
				elseif v.Name == "Head" then
					local face = v.face
					face.Transparency = 0
				end
			end
		end
	}
}

--// Functions //--

--[[ extra info
CurrentCamera - im gonna forget this so here
0 = normal camera without lock
1 = free/normal camera
2 = third person mouse lock (just like a shift lock)
3 = first person
10 = unlocked mouse (locks camera and all movement)
]]

--[[

local _, y = workspace.CurrentCamera.CFrame.Rotation:ToEulerAnglesYXZ() --Get the angles of the camera
root.CFrame = CFrame.new(root.Position) * CFrame.Angles(0,y,0)

]]

-- mouse lock updates
function CameraHandler.LockMouse(player : Player, character : Model, CameraInt : IntValue, Active : string)	
	if CameraInt == nil then error("cameraType Int == nil") return end
	
	local Previous 
	local CameraType
	
	if CameraInt == 0 then
		Previous = tostring(2)
		CameraType = tostring(CameraInt)
	else
		Previous = tostring(CameraInt - 1)
		CameraType = tostring(CameraInt)
	end
	
	print(Previous)
	print(CameraType)
	
	uis.MouseBehavior = Enums[CameraType]["MouseBehavior"]
	player.CameraMode = Enums[CameraType]["CameraMode"]
	humanoid.AutoRotate = Enums[CameraType]["AutoRotate"]
	
	camera.CameraSubject = character:WaitForChild(Enums[CameraType]["Subject"])
	if Enums[Previous]["OnExit"] then
		Enums[Previous]["OnExit"]()
	else
		print("no OnExit for cameratype")
	end
	
	if Enums[CameraType]["OnEnter"] then
		Enums[CameraType]["OnEnter"]()
	end
end

function CameraHandler.Update(player, action, ActiveStates, active)
	local FOVtween
	
	if active == "InputBegan" then
		FOVtween = tween:Create(camera, TweenInfo.new(0.5, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {FieldOfView = action["FOV"]})
	else
		if ActiveStates ~= 0 then print("playerstate # == 0") return end
		print("fov changing, playerstate == " .. ActiveStates)
		
		FOVtween = tween:Create(camera, TweenInfo.new(0.5, Enum.EasingStyle.Quad, Enum.EasingDirection.In), {FieldOfView = DefaultConfig.Camera["FOV"]})
	end
	
	FOVtween:Play()
end

-- for more specific tweens that require longer code and more detail such as gradual increase
function CameraHandler.Tween(player, State)
	
end

return CameraHandler

There is more to the system but these are the main few, if I need I can send more.

Solid work for someone new. A server-auth state system with priority resolution and proper client-server separation isn’t much of a beginner stuff. Most new developers wouldn’t think to do it this way. The instincts are right and there’s nothing here that needs a redesign.

But what to fix

  • Add players.PlayerRemoving cleanups. PlayerState[player] remains in memory after a player leaves.
  • Recalculate HighestPriority on state exit. Right now it flatly resets to 0 even if other states are still active.
  • Deduplicate the InputBegan/InputEnded blocks in your ClientHandler. They’re identical. You can wrap them in a helper function.
local function SendInput(keyCode, phase)
    if ActionRequest.Id == nil then
        error("Packet does not exist on server side")
    end
    ActionRequest:Fire(keyCode, phase)
end

uis.InputBegan:Connect(function(input)
    if input.UserInputType == Enum.UserInputType.Keyboard then
        SendInput(input.KeyCode, "InputBegan")
    end
end)

uis.InputEnded:Connect(function(input)
    if input.UserInputType == Enum.UserInputType.Keyboard then
        SendInput(input.KeyCode, "InputEnded")
    end
end)
1 Like

I’ve been doing my own research and using some A.I to figure out how big games like JJS or TSB make their StateSystem and this is my second attempt at this; trial and error I guess. I’ll reply to each of the changes you recommended.

  • I did plan on adding the players.PlayerRemoving clean-up, just had been putting off due to other errors I have been smoothing out over the past week of me posting this. Simple terms me being lazy

  • I have been re-working the HighestPriority coding due to the implementation of animations and the updated CameraHandler, but I believe I have it to check whether it should or should not change the value upon exit. I’ll look into the code and areas you were highlighting.

  • I didn’t realize that sending the inputs was pretty much identical with some value changes, guess I never thought about wrapping it into one function lol. Thank you for pointing that out.

Just updated all the code due to a large amount of reworking for the AnimationHandler that I need and the camera tween effects.

Again thank you for replying and the feedback you gave, I have been coding on Roblox for less than a year now so this is my first functional StateSystem. I’ll update all the code tomorrow

1 Like

I just updated the code with the changes I had made and what you recommended, thank you again for the help and insight.

1 Like