Skip to content

Getting Started

iKryptonic edited this page May 1, 2026 · 13 revisions

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.


What you will build

By the end of this guide you will have:

  1. synced the framework into Studio with Rojo
  2. created a schema-backed SpinnerEntity
  3. created a SpinnerFSM that uses ScheduleTransition() and Timeout
  4. booted the runtime on the server and client
  5. launched ServiceManager for live inspection
  6. seen a part repeatedly switch between idle and spinning states

Prerequisites

Required tools

  • Roblox Studio
  • Rojo 7.6.1+
    • this repository pins rojo-rbx/rojo@7.6.1 in aftman.toml
  • Git for cloning the repo or copying the framework into your own place file

Required knowledge

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, and LocalScript
  • how ReplicatedStorage is shared between server and client

What the runtime expects

The runtime boots cleanly when it can find:

  • ReplicatedStorage.Orchestrator β†’ the framework itself
  • at least one folder named Entity or Entities
  • at least one folder named StateMachine, StateMachines, SM, or FSM

Factory.Initialize() scans all game descendants for those folder aliases, but the recommended layout is:

ReplicatedStorage/
β”œβ”€β”€ Orchestrator/
β”œβ”€β”€ Entity/
└── StateMachine/

Step 1: Install the framework with Rojo

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.

Recommended Rojo tree

{
  "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"
        }
      }
    }
  }
}

Start Rojo

rojo serve

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


Step 2: Understand the project structure

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

Step 3: Create your first Entity

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 SpinnerEntity

Why this Entity works

This module matches the current BaseEntity contract in Core/Factory/BaseEntity.luau:

  • Name identifies the class during factory lookup
  • Mutable = true allows UpdateEntity() to commit staged writes
  • Schema defines the allowed properties and their types
  • GetContext() derives runtime-only references such as Instance
  • ApplyChanges() reacts to committed state changes

Important: Mutable = true

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 = true

Step 4: Create your first FSM

Create 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 SpinnerFSM

What this demonstrates

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


Step 5: Add the server bootstrap

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")

Why this is enough

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" })

Step 6: Add the client bootstrap

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")

What happens on the client

During RegisterComponents() the client:

  1. initializes the same core modules
  2. requests RequestEntitySnapshot from the server
  3. creates local mirror entities using InitialData
  4. replays any buffered updates that arrived before the entity existed
  5. becomes ready for ServiceManager inspection

StartServiceManager() is intentionally a separate step. Boot the runtime first, then launch the dashboard only when you want it.


Step 7: Understand what RegisterComponents() actually does

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.


Step 8: Run the place and verify the result

When you press Play in Studio, expect the following:

Visual result

  • 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

Console output

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

ServiceManager verification

If you launched ServiceManager on the client:

  • TASKS should show GlobalStateMachineHeartbeat and ServiceManagerBrainTick
  • FSM should show SpinnerPart_01_FSM
  • ENTITY should show SpinnerPart_01
  • PROFILER should show the heartbeat cost and your FSM's update timings

Step 9: Read the runtime state like a debugger

This first example is intentionally simple enough that you can map runtime behavior directly to source code.

Entity lifecycle

staged write   -> entity.IsSpinning = true
commit         -> entity:UpdateEntity()
version bump   -> _v increments
replication    -> server broadcasts replicated fields
render hook    -> ApplyChanges(changes)

FSM lifecycle

CreateStateMachine()
  -> class resolved in Factory
  -> FSM registered in Registry
  -> Start({State="Idle"})
  -> GlobalStateMachineHeartbeat drives BaseStateMachine.Step(dt)
  -> _Update(dt) evaluates timers, transitions, and OnHeartbeat

Why the spinner keeps working

  • Idle schedules a native timed transition
  • leaving Idle cancels any stale timer automatically
  • Spinning uses Timeout so the deadline belongs to the state itself
  • the scheduler provides a shared heartbeat instead of one connection per FSM

Common mistakes during first setup

1. Requiring the wrong module path

Wrong for the default project in this repo:

local FSM = require(ReplicatedStorage.RBXStateMachine)

Correct for the current repo mapping:

local Orchestrator = require(ReplicatedStorage.Orchestrator)

2. Forgetting Mutable = true

If you see UpdateEntity failed: Attempt to call UpdateEntity on immutable entity, add Mutable = true to the entity definition.

3. Putting your modules in the wrong folder

Auto-discovery only scans folders whose names match the built-in aliases:

  • Entity, Entities
  • StateMachine, StateMachines, SM, FSM

4. Calling :Start() twice

CreateStateMachine() already auto-starts unless AutoStart = false. A second :Start() is usually harmless, but it makes the boot flow harder to reason about.

5. Using schema for runtime-only references

Put things like Instance, Humanoid, or Animator in Context, not in replicated schema fields.

6. Expecting ServiceManager to appear automatically

It does not. The runtime boot and the dashboard launch are separate.

Orchestrator:RegisterComponents()
Orchestrator:StartServiceManager()

Where to go next

Once this first spinner is working, you have the entire core loop online: Factory, Registry, Scheduler, replication, and ServiceManager.

🏠 Home


πŸš€ Getting Started


πŸ—οΈ Architecture


πŸ”§ API Reference


πŸ“š Guides


βš™οΈ Advanced


πŸ› οΈ Development


πŸ“– Reference

Clone this wiki locally