-
Notifications
You must be signed in to change notification settings - Fork 0
Getting Started
A complete from-zero walkthrough for booting the current FSM/Orchestrator runtime, registering your first Entity and FSM, and verifying the whole stack in Studio.
Important
This guide is based on the current source in FSM/Orchestrator/init.luau, Core/Factory/*, example/*, test/main.server.luau, test/Client.client.luau, and default.project.json.
The default Rojo project in this repository syncs the framework as ReplicatedStorage.Orchestrator, not ReplicatedStorage.RBXStateMachine. All examples below use the current source-of-truth path.
By the end of this guide you will have:
- synced the framework into Studio with Rojo
- created a schema-backed
SpinnerEntity - created a
SpinnerFSMthat usesScheduleTransition()andTimeout - booted the runtime on the server and client
- launched ServiceManager for live inspection
- seen a part repeatedly switch between idle and spinning states
- Roblox Studio
-
Rojo 7.6.1+
- this repository pins
rojo-rbx/rojo@7.6.1inaftman.toml
- this repository pins
- Git for cloning the repo or copying the framework into your own place file
You do not need to know the entire framework first, but it helps to understand:
- the difference between server and client scripts in Roblox
- how to create
ModuleScript,Script, andLocalScript - how
ReplicatedStorageis shared between server and client
The runtime boots cleanly when it can find:
-
ReplicatedStorage.Orchestratorβ the framework itself - at least one folder named
EntityorEntities - at least one folder named
StateMachine,StateMachines,SM, orFSM
Factory.Initialize() scans all game descendants for those folder aliases, but the recommended layout is:
ReplicatedStorage/
βββ Orchestrator/
βββ Entity/
βββ StateMachine/
The repository's own default.project.json maps:
-
FSM/OrchestratorβReplicatedStorage.Orchestrator -
exampleβReplicatedStorage.Examples -
test/main.server.luauβServerScriptService.TestBootstrap -
test/Client.client.luauβStarterPlayer.StarterPlayerScripts.ClientBootstrap
In your own game, you usually want to add your custom Entity and StateMachine folders too.
{
"name": "MyGame",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Orchestrator": {
"$path": "RBXStateMachine/FSM/Orchestrator"
},
"Entity": {
"$path": "src/Entity"
},
"StateMachine": {
"$path": "src/StateMachine"
}
},
"ServerScriptService": {
"$className": "ServerScriptService",
"Bootstrap": {
"$path": "src/Bootstrap.server.luau"
}
},
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$className": "StarterPlayerScripts",
"ClientBootstrap": {
"$path": "src/ClientBootstrap.client.luau"
}
}
}
}
}rojo serveThen connect the running Rojo server from Roblox Studio.
Tip
If you want a known-good local harness before integrating into your own game, start with this repository's built-in default.project.json. It already boots the test scripts and example folder.
A minimal working setup looks like this:
ReplicatedStorage/
βββ Orchestrator/ β synced from FSM/Orchestrator
β βββ Core/
β βββ ServiceManager/
β βββ init.luau
βββ Entity/
β βββ SpinnerEntity.luau
βββ StateMachine/
β βββ SpinnerFSM.luau
ServerScriptService/
βββ Bootstrap.server.luau
StarterPlayer/
βββ StarterPlayerScripts/
βββ ClientBootstrap.client.luau
Here is the high-level boot flow:
Rojo sync
βββ ReplicatedStorage.Orchestrator
βββ Server Bootstrap
β βββ Orchestrator:RegisterComponents
β βββ Factory compiles Entity and StateMachine modules
β βββ CreateEntity
β βββ CreateStateMachine
β βββ Scheduler drives GlobalStateMachineHeartbeat
β βββ FSM transitions + entity updates
βββ Client Bootstrap
βββ Orchestrator:RegisterComponents
β βββ SyncEntitiesFromServer
βββ Orchestrator:StartServiceManager
Create ReplicatedStorage/Entity/SpinnerEntity.luau:
-- ReplicatedStorage/Entity/SpinnerEntity.luau
local RunService = game:GetService("RunService")
local SpinnerEntity = {
Name = "SpinnerEntity",
Mutable = true,
Replication = {
Enabled = true,
RateLimit = 20,
},
Schema = {
IsSpinning = {
Type = "boolean",
Default = false,
Replicate = true,
Description = "Whether the part should currently spin",
},
SpinRate = {
Type = "number",
Default = 90,
Replicate = true,
Description = "Degrees per second",
},
Tint = {
Type = "Color3",
Default = Color3.fromRGB(255, 170, 0),
Replicate = true,
Description = "Visual state color",
},
},
}
function SpinnerEntity:GetContext(params)
local instance = params.Context and params.Context.Instance
assert(instance and instance:IsA("BasePart"), "SpinnerEntity requires Context.Instance = BasePart")
return {
Instance = instance,
}
end
function SpinnerEntity:ApplyChanges(changes)
local instance = self.Instance
if not instance then
return
end
if changes.Tint ~= nil then
instance.Color = changes.Tint
end
if RunService:IsClient() and changes.IsSpinning ~= nil then
instance.Material = changes.IsSpinning and Enum.Material.Neon or Enum.Material.SmoothPlastic
end
end
return SpinnerEntityThis module matches the current BaseEntity contract in Core/Factory/BaseEntity.luau:
-
Nameidentifies the class during factory lookup -
Mutable = trueallowsUpdateEntity()to commit staged writes -
Schemadefines the allowed properties and their types -
GetContext()derives runtime-only references such asInstance -
ApplyChanges()reacts to committed state changes
This is easy to miss.
BaseEntity:UpdateEntity() fast-fails when the entity is immutable. If you intend to do this:
entity.IsSpinning = true
entity:UpdateEntity()then the entity definition must declare:
Mutable = trueCreate ReplicatedStorage/StateMachine/SpinnerFSM.luau:
-- ReplicatedStorage/StateMachine/SpinnerFSM.luau
local SpinnerFSM = {
Name = "SpinnerFSM",
ValidStates = { "Idle", "Spinning" },
InitialState = "Idle",
}
function SpinnerFSM:RegisterStates()
self:AddState("Idle", function(fsm)
local entity = fsm.Entity
entity.IsSpinning = false
entity.Tint = Color3.fromRGB(255, 170, 0)
entity:UpdateEntity()
-- Wait 1 second, then enter Spinning.
fsm:ScheduleTransition(1, "Spinning")
end, { "Spinning" })
self:AddState("Spinning", {
OnEnter = function(state, fsm)
local entity = fsm.Entity
entity.IsSpinning = true
entity.Tint = Color3.fromRGB(0, 255, 127)
entity:UpdateEntity()
end,
OnHeartbeat = function(state, fsm, dt)
local entity = fsm.Entity
local part = entity.Instance
if not part then
return
end
local degreesPerSecond = entity.SpinRate or 90
part.CFrame *= CFrame.Angles(0, math.rad(degreesPerSecond) * dt, 0)
end,
Timeout = 3,
OnTimeout = "Idle",
}, { "Idle" })
end
return SpinnerFSMThis tiny FSM uses two of the most important runtime timing tools:
-
ScheduleTransition(1, "Spinning")β a one-shot timed exit from the current state -
Timeout = 3+OnTimeout = "Idle"β a state-owned timeout that resets every time the state is entered
Both are implemented in Core/Factory/BaseStateMachine.luau and are easier to debug than handwritten task.delay closures.
Create ServerScriptService/Bootstrap.server.luau:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Orchestrator = require(ReplicatedStorage:WaitForChild("Orchestrator"))
Orchestrator:RegisterComponents()
local part = Instance.new("Part")
part.Name = "SpinnerPart"
part.Anchored = true
part.Size = Vector3.new(4, 4, 4)
part.Position = Vector3.new(0, 6, 0)
part.Color = Color3.fromRGB(255, 170, 0)
part.Parent = workspace
local spinnerEntity = Orchestrator.CreateEntity({
EntityClass = "SpinnerEntity",
EntityId = "SpinnerPart_01",
Context = {
Instance = part,
},
InitialData = {
SpinRate = 120,
},
})
assert(spinnerEntity, "Failed to create SpinnerEntity")
local spinnerFSM = Orchestrator.CreateStateMachine({
StateMachineClass = "SpinnerFSM",
StateMachineId = "SpinnerPart_01_FSM",
Context = {
Entity = spinnerEntity,
EntityId = "SpinnerPart_01",
},
InitialState = "Idle",
})
assert(spinnerFSM, "Failed to create SpinnerFSM")
print("[Server] Spinner demo booted")Factory.CreateStateMachine() currently auto-starts by default. Passing InitialState = "Idle" here is enough to boot the machine.
You only need a manual :Start() when you intentionally opt out:
local fsm = Orchestrator.CreateStateMachine({
StateMachineClass = "SpinnerFSM",
StateMachineId = "SpinnerPart_01_FSM",
Context = { Entity = spinnerEntity },
AutoStart = false,
})
fsm:Start({ State = "Idle" })Create StarterPlayerScripts/ClientBootstrap.client.luau:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Orchestrator = require(ReplicatedStorage:WaitForChild("Orchestrator", 30))
Orchestrator:RegisterComponents()
if RunService:IsStudio() then
local sm = Orchestrator:StartServiceManager()
sm.SwitchSubsystem("FSM")
sm.SetContextMode("server")
end
print("[Client] Orchestrator ready")During RegisterComponents() the client:
- initializes the same core modules
- requests
RequestEntitySnapshotfrom the server - creates local mirror entities using
InitialData - replays any buffered updates that arrived before the entity existed
- becomes ready for ServiceManager inspection
StartServiceManager() is intentionally a separate step. Boot the runtime first, then launch the dashboard only when you want it.
The current initialization path in FSM/Orchestrator/init.luau is worth knowing because it explains most startup failures.
ServerBootstrap Orchestrator Factory Scheduler NetworkManager ServiceManager
β β β β β β
βββ RegisterComponents() βββββββββββββββ β β β
β βββ Initialize Logger β β β
β βββ Create shared.fsm proxy β β β
β βββ Initialize(Core) βββββββββββββββββββββββββββββββββββββ β
β βββ Initialize(Core) βββββββββββββββββ β β
β β βββ Scan Entity / StateMachine folders β
β β βββ Compile ModuleScripts into classes β
β βββ Initialize(Core) βββββββββββββββββββββββ β β
β βββ new() ββββββββββββββββββββββββββββββββββ β β
β βββ Start() ββββββββββββββββββββββββββββββββ β β
β βββ Schedule(GlobalStateMachineHeartbeat) βββ β β
β βββ Register snapshot callbacks β β β
β βββ RegisterServerHandlers(self) βββββββββββββββββββββββββββββββββββββββββββ
The client path is almost the same, except the end of bootstrap calls SyncEntitiesFromServer() instead of registering server handlers.
When you press Play in Studio, expect the following:
- a part appears at
(0, 6, 0) - it starts orange and idle
- after 1 second it turns green and spins
- after 3 seconds it returns to orange and stops
- the cycle repeats forever
You should see a mix of framework and bootstrap messages similar to:
[INFO] Initializing Orchestrator
[INFO] SetSharedVariables completed
[INFO] Scheduler started
[INFO] NetworkManager initialized
[INFO] Callbacks registered
[INFO] SyncEntitiesFromServer completed
[Server] Spinner demo booted
[Client] Orchestrator ready
If you launched ServiceManager on the client:
-
TASKS should show
GlobalStateMachineHeartbeatandServiceManagerBrainTick -
FSM should show
SpinnerPart_01_FSM -
ENTITY should show
SpinnerPart_01 - PROFILER should show the heartbeat cost and your FSM's update timings
This first example is intentionally simple enough that you can map runtime behavior directly to source code.
staged write -> entity.IsSpinning = true
commit -> entity:UpdateEntity()
version bump -> _v increments
replication -> server broadcasts replicated fields
render hook -> ApplyChanges(changes)
CreateStateMachine()
-> class resolved in Factory
-> FSM registered in Registry
-> Start({State="Idle"})
-> GlobalStateMachineHeartbeat drives BaseStateMachine.Step(dt)
-> _Update(dt) evaluates timers, transitions, and OnHeartbeat
-
Idleschedules a native timed transition - leaving
Idlecancels any stale timer automatically -
SpinningusesTimeoutso the deadline belongs to the state itself - the scheduler provides a shared heartbeat instead of one connection per FSM
Wrong for the default project in this repo:
local FSM = require(ReplicatedStorage.RBXStateMachine)Correct for the current repo mapping:
local Orchestrator = require(ReplicatedStorage.Orchestrator)If you see UpdateEntity failed: Attempt to call UpdateEntity on immutable entity, add Mutable = true to the entity definition.
Auto-discovery only scans folders whose names match the built-in aliases:
-
Entity,Entities -
StateMachine,StateMachines,SM,FSM
CreateStateMachine() already auto-starts unless AutoStart = false. A second :Start() is usually harmless, but it makes the boot flow harder to reason about.
Put things like Instance, Humanoid, or Animator in Context, not in replicated schema fields.
It does not. The runtime boot and the dashboard launch are separate.
Orchestrator:RegisterComponents()
Orchestrator:StartServiceManager()- Want the smallest possible copy-paste bootstrap? β Quick Start
- Want the big-picture runtime map? β Architecture
- Want timing guidance? β Best Practices
- Want real patterns from the repository examples? β Examples
- Want to understand class compilation and discovery? β Registration Guide
Once this first spinner is working, you have the entire core loop online: Factory, Registry, Scheduler, replication, and ServiceManager.
Quick Links: Home Β· Quick Start Β· API Reference Β· Architecture Β· Examples Β· Glossary
Copyright: Β© 2026 RBXStateMachine contributors Β· Repository Β· License information