-
Notifications
You must be signed in to change notification settings - Fork 0
API NetworkManager
NetworkManager owns the Orchestrator remotes, clientβserver request routing, entity command transport, local EventBus instances, and the telemetry consumed by the ServiceManager NETWORK panel.
Important
Docs audit note (2026-05): This page was re-audited against FSM/Orchestrator/Core/NetworkManager.luau and the wrapper methods in FSM/Orchestrator/init.luau.
NetworkManager is initialized once during Orchestrator:RegisterComponents(). It:
- creates or resolves four remotes from
Settings.StaticStrings.Instance - creates
EventBusRegisteredas a localSignal - installs either server listeners or client listeners depending on
RunService - records transport telemetry in
_remoteStats,_invokeStats, and_busStats
Most callers should go through the Orchestrator wrappers:
local FSM = require(game:GetService("ReplicatedStorage").RBXStateMachine)
FSM.Orchestrator:RegisterComponents()
-- client only
local result = FSM.Orchestrator:ServerRequest("GetProfile", player.UserId)
-- shared
local bus = FSM.Orchestrator:RegisterEventBus("InventoryChanged")NetworkManager.ValidateRequest(context?) hard-asserts when a method is called from the wrong VM.
| API family | Allowed context | What happens on the wrong side |
|---|---|---|
RegisterServerRequest, _RegisterServerListeners, BroadcastEntityCommand, BroadcastEntityUpdate, UnregisterEntityCommands
|
Server | assert |
ServerRequest, ServerRequestAsync, ServerCommandEntity, _RegisterClientListeners
|
Client | assert |
RegisterEntityCommand, EventBus APIs, Initialize, Log
|
Shared | no context restriction beyond initialization |
Initialize(Util) is idempotent. Every other method asserts if initialization never happened.
| Field | Roblox class | Direction | Primary use |
|---|---|---|---|
OrchestratorEvent |
RemoteEvent |
Client β Server | fire-and-forget server requests (ServerRequestAsync) |
OrchestratorFunction |
RemoteFunction |
Client β Server | synchronous request/response (ServerRequest) |
EntityUpdateEvent |
RemoteEvent |
Server β Client | replicated entity deltas |
EntityCommandEvent |
RemoteEvent |
both | clientβserver entity commands and serverβclient broadcasts |
CreateRemote(name, className) behavior:
-
server:
FindFirstChildβ reuse compatible remote, else create it inReplicatedStorage -
client:
WaitForChild(name) -
name == nilreturnsnil
Client code Orchestrator wrapper NetworkManager (client) OrchestratorFunction NetworkManager (server) Registered handler
β β β β β β
βββ ServerRequest("GetProfile", userId) ββββββββ β β β β
β βββ ServerRequest("GetProfile", userId) ββββββββββ β β β
β β βββ InvokeServer("AppRequestSync", "GetProfile", userId) βββββββββββββββββ β
β β β βββ HandleAppRequest(player, ...) βββββββββββββββββ β
β β β β βββ handler(player, userId) ββββ
β β β β ββββ result ββββββββββββββββ
β β β ββββ result ββββββββββββββββββββββββββββββββββββββ
β β ββββ result ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ββββ result βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββ result ββββββββββββββ β β β β
[Server entity changes] βββ [BroadcastEntityUpdate(entityId, changes)]
βββ [EntityUpdateEvent:FireAllClients]
βββ [Orchestrator client listener]
ββββ [RecordInboundCall("EntityUpdateEvent", "SβC", ...)]
ββββ [version + schema validation] βββ [entity:ApplyChanges(visualChanges)]
RecordInboundCall is installed by _RegisterClientListeners() specifically so the separate entity replication listener in Orchestrator/init.luau can still feed ServiceManager telemetry.
NetworkManager keeps three independent stats maps:
| Field | Key shape | Used for |
|---|---|---|
_invokeStats |
requestName or entityId .. ":" .. command
|
handler invocation counts / last-invoked time |
_remoteStats |
"Remote:" .. remoteName |
transport-level remote history |
_busStats |
busName |
local EventBus history |
Stored by trackRemoteCall(remoteName, direction, packedArgs).
type RemoteRecentCall = {
Time: number,
Direction: string,
Args: string,
ArgsStruct: {
[number]: any,
n: number,
truncated: number?,
},
}
type RemoteBucket = {
Count: number,
LastFired: number,
RecentCalls: { RemoteRecentCall },
}Important key shapes:
Remote:OrchestratorFunctionRemote:OrchestratorEventRemote:EntityUpdateEventRemote:EntityCommandEventRemote:ServerRequest:GetProfileRemote:EntityCommand:Player_42:Equip
Each bucket keeps only the most recent 30 calls.
FireEventBus(name, ...) stores:
{
Count = number,
LastFired = number,
RecentCalls = {
{
Time = number,
Args = string,
ArgsStruct = { ... },
},
},
}_invokeStats is transport-agnostic. It counts how often a registered callback actually ran, even if multiple callback families share a remote.
- sync request key:
requestName - async request key:
requestName - entity command key:
entityId .. ":" .. command
ServiceManager's NETWORK view reads both a compact preview string and a structured copy.
formatArgsInline(packed) rules:
- previews only the first 4 arguments
- top-level strings are quoted with
%q - numbers / booleans /
nilstay inline - tables render as
{k=v, k2=v2, +N}with at most 3 keys shown - string values inside table previews are truncated after roughly 10 visible chars (
abcdefghij..) - overflow is shown as
...+N - unsupported types use RichText-safe square-bracket markers such as
[userdata:Part]or[function]
sanitizeForReplication(value, depth) rules:
- recursion depth over 4 becomes
"[...]" - each table level keeps at most 16 keys
- extra table keys produce
"β¦" = "(+N more)" - only the first 8 arguments are copied into the struct
- extra arguments set
ArgsStruct.truncated
Example:
local packed = table.pack("equip", { Slot = 1, Meta = { Rarity = "Epic", Seed = 12 } }, workspace.Part)
-- Args => ("equip", {Slot=1, Meta={...}}, [userdata:Part])
-- ArgsStruct => {
-- [1] = "equip",
-- [2] = { Slot = 1, Meta = { Rarity = "Epic", Seed = 12 } },
-- [3] = "[userdata:Part]",
-- n = 3,
-- }One-time bootstrap.
Side effects
- sets
NetworkManager.Logger = Util.Logger.new({ Name = "NetworkManager" }) - caches
Signal = Util.Signal - resolves / creates the four remotes
- creates
EventBusRegistered = Signal.new() - installs server or client listeners
- logs
"NetworkManager initialized"
Edge cases
- second call is a no-op
- on the client, missing remotes will block in
WaitForChild
Thin wrapper around the module logger.
Side effects
- writes to Logger history
- may print/warn depending on Logger settings
NetworkManager.RegisterServerRequest(name: string, handler: (player: Player, ...any) -> any, isAsync: boolean?) -> ()
Registers a named RPC handler.
-
isAsync == truestores intoServerRequestCallbacksAsync - otherwise stores into
ServerRequestCallbacksSync - duplicate names overwrite the previous handler in that table
- server only
Async vs sync behavior
- sync requests are invoked through
RemoteFunction - async requests are triggered through
RemoteEvent - async handler return values are ignored by transport callers
-- server
FSM.Orchestrator:RegisterServerRequestCallback("GetProfile", function(player, userId: number)
return ProfileStore[userId]
end, false)
FSM.Orchestrator:RegisterServerRequestCallback("QueueSave", function(player, userId: number)
SaveQueue[userId] = true
end, true)NetworkManager.RegisterEntityCommand(entityId: string, command: string, handler: (player: Player?, ...any) -> ()) -> ()
Registers a per-entity command handler under EntityCommandCallbacks[entityId][command].
Context behavior
- on the server, handlers run for clientβserver
ServerCommandEntitytraffic and receive the realplayer - on the client, handlers run for server broadcasts and receive
nilforplayer
Edge cases
- creating the first handler for an entity creates a new nested table
- duplicate
(entityId, command)pairs overwrite the previous handler
-- client: react to a server broadcast for a specific replicated entity
FSM.Orchestrator:RegisterEntityCommandCallback("Enemy_12", "PlayHitFlash", function(_player, color)
flashEnemy(color)
end)Server-only helper that removes the entire command table for one entity.
Side effects
EntityCommandCallbacks[entityId] = nil- logs the removal
Client-only fire-and-forget entity command.
Transport payload
EntityCommandEvent:FireServer(entityId, command, ...)Telemetry
- tracks
EntityCommandEventasCβS - tracked payload includes
entityId,command, and all user args
Edge cases
- if
EntityCommandEventis missing, this silently does nothing
-- client
FSM.Orchestrator:ServerCommandEntity("Door_3", "Open", true)Server-only broadcast to all clients.
Transport payload
EntityCommandEvent:FireAllClients(entityId, command, ...)Telemetry
- tracks
EntityCommandEventasSβAll
-- server
Utility.NetworkManager.BroadcastEntityCommand("Boss_1", "PlayRoar", { Volume = 0.8 })Server-only replicated delta broadcast.
Transport payload
EntityUpdateEvent:FireAllClients(entityId, changes)Side effects outside NetworkManager
- the client-side Orchestrator listener applies version checks, schema checks, pending-update buffering, and finally
entity:ApplyChanges(...)
Telemetry
- tracks
EntityUpdateEventasSβAll
Client-only synchronous RPC.
Transport payload
OrchestratorFunction:InvokeServer("AppRequestSync", requestName, ...)Returns
- handler return value on success
-
nilif the remote is unavailable, the name is unregistered, or the handler throws
Telemetry
- tracks
OrchestratorFunctionasCβS (sync) - server-side listener separately tracks
Remote:ServerRequest:<requestName>and_invokeStats[requestName]
-- client
local profile = FSM.Orchestrator:ServerRequest("GetProfile", player.UserId)
if profile then
print(profile.Level)
endClient-only fire-and-forget RPC.
Transport payload
OrchestratorEvent:FireServer("AppRequestAsync", requestName, ...)Important
- there is no client response channel on this API
- server handler failures are logged server-side through
NetworkManager.Log
-- client
FSM.Orchestrator:ServerRequestAsync("QueueSave", player.UserId)Creates or returns a local named Signal.
Side effects
- if new, stores
Signal.new()inEventBuses[name] - fires
EventBusRegistered:Fire(name)for watchers waiting on registration - logs registration
Edge cases
- calling it twice returns the existing bus without creating a second one
local inventoryBus = FSM.Orchestrator:RegisterEventBus("InventoryChanged")Destroys and removes a named bus.
Side effects
- calls
bus:Destroy()when the bus has aDestroymethod - clears
EventBuses[name] - logs unregistration
Edge cases
- unregistering a missing bus is safe
- old references to the destroyed bus still exist as Lua values, but their handler list was cleared by
Signal:Destroy()
Returns the named bus or nil.
Fires a named bus if it exists.
Side effects
- increments
_busStats[name].Count - updates
_busStats[name].LastFired - appends a recent call with both
ArgsandArgsStruct - fires the underlying
Signal
Edge cases
- missing bus => silent no-op
- recent call history is capped at 30
local bus = FSM.Orchestrator:RegisterEventBus("InventoryChanged")
bus:Connect(function(itemId, count)
print(itemId, count)
end)
FSM.Orchestrator:FireEventBus("InventoryChanged", "Potion", 3)Polls until a named bus exists, then returns the bus object itself.
Important
- this waits for registration, not for the next event payload
- polling interval is
task.wait(0.1) - returns
nilwhentimeoutelapses
local bus = FSM.Orchestrator:AwaitEventBus("InventoryChanged", 5)
if bus then
bus:Connect(function(...) print(...) end)
endClient-side helper installed by _RegisterClientListeners().
It exists so other inbound consumers β currently the entity replication listener in Orchestrator/init.luau β can record receive-side traffic without owning trackRemoteCall().
Not available until client listeners have been registered.
DisconnectTrackedConnections() disconnects _connections back-to-front and nils each slot.
Both _RegisterServerListeners() and _RegisterClientListeners() call it before wiring fresh listeners, so hot reload / re-init does not double-subscribe tracked connections.
Tracked listeners include:
- server
OrchestratorEvent.OnServerEvent - server
EntityCommandEvent.OnServerEvent - client
EntityCommandEvent.OnClientEvent
OrchestratorFunction.OnServerInvoke is replaced directly, so it is not stored in _connections.
-- Server bootstrap
FSM.Orchestrator:RegisterServerRequestCallback("GetProfile", function(player, userId: number)
if player.UserId ~= userId then
return nil
end
return {
Coins = 125,
Loadout = { Primary = "Sword" },
}
end, false)
-- Client usage
local profile = FSM.Orchestrator:ServerRequest("GetProfile", game.Players.LocalPlayer.UserId)
if profile then
print(profile.Coins)
end-- Server
FSM.Orchestrator:RegisterServerRequestCallback("QueueSave", function(player, slotId: string)
SaveQueue[player.UserId] = slotId
end, true)
-- Client
FSM.Orchestrator:ServerRequestAsync("QueueSave", "slot_a")-- Server: accept a command from the owning client
FSM.Orchestrator:RegisterEntityCommandCallback("Door_1", "Open", function(player, fastOpen: boolean)
if not player then return end
openDoorForAllClients(fastOpen)
FSM.Orchestrator:BroadcastEntityCommand("Door_1", "PlayOpenFx", fastOpen)
end)
-- Client: react to the broadcast
FSM.Orchestrator:RegisterEntityCommandCallback("Door_1", "PlayOpenFx", function(_player, fastOpen: boolean)
playDoorFx(fastOpen)
end)
-- Client: ask the server to open it
FSM.Orchestrator:ServerCommandEntity("Door_1", "Open", true)task.spawn(function()
local bus = FSM.Orchestrator:AwaitEventBus("Combat.RoundStarted", 10)
if not bus then
warn("Combat.RoundStarted was never registered")
return
end
bus:Connect(function(roundId)
print("round", roundId, "started")
end)
end)
-- Somewhere else later:
FSM.Orchestrator:RegisterEventBus("Combat.RoundStarted")
FSM.Orchestrator:FireEventBus("Combat.RoundStarted", 17)-
Unregistered request name returns
nil.HandleAppRequestsimply returnsnilwhen no handler exists. -
Async request errors never reach the client. The server wraps handlers in
pcall; failures are logged and dropped. -
AwaitEventBusdoes not wait for an event payload. It waits for the bus object to exist. -
Client entity-command handlers receive
nilforplayer. Do not write client handlers that expect aPlayerobject. -
Telemetry is intentionally lossy.
ArgsStructdepth, breadth, and arg count are capped for UI safety. -
Remotes can silently no-op when missing.
ServerRequest,ServerRequestAsync,ServerCommandEntity,BroadcastEntityCommand, andBroadcastEntityUpdateall check the remote field before firing. -
Listener re-registration only cleans tracked RBXScriptConnections. If outside code adds more listeners to the remotes, NetworkManager will not manage those.
Quick Links: Home Β· Quick Start Β· API Reference Β· Architecture Β· Examples Β· Glossary
Copyright: Β© 2026 RBXStateMachine contributors Β· Repository Β· License information