Skip to content

API NetworkManager

iKryptonic edited this page May 1, 2026 · 4 revisions

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.


🎯 Overview

NetworkManager is initialized once during Orchestrator:RegisterComponents(). It:

  1. creates or resolves four remotes from Settings.StaticStrings.Instance
  2. creates EventBusRegistered as a local Signal
  3. installs either server listeners or client listeners depending on RunService
  4. 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")

🧭 Context rules

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.


πŸ”Œ Remote topology

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 in ReplicatedStorage
  • client: WaitForChild(name)
  • name == nil returns nil

πŸ” Request / replication flow

Sync request path

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 ─────────────│                         β”‚                          β”‚                          β”‚                        β”‚

Entity replication path

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


πŸ“Š Telemetry and drill-down data

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

_remoteStats

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:OrchestratorFunction
  • Remote:OrchestratorEvent
  • Remote:EntityUpdateEvent
  • Remote:EntityCommandEvent
  • Remote:ServerRequest:GetProfile
  • Remote:EntityCommand:Player_42:Equip

Each bucket keeps only the most recent 30 calls.

_busStats

FireEventBus(name, ...) stores:

{
    Count = number,
    LastFired = number,
    RecentCalls = {
        {
            Time = number,
            Args = string,
            ArgsStruct = { ... },
        },
    },
}

_invokeStats

_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

🧾 Args vs ArgsStruct

ServiceManager's NETWORK view reads both a compact preview string and a structured copy.

Args

formatArgsInline(packed) rules:

  • previews only the first 4 arguments
  • top-level strings are quoted with %q
  • numbers / booleans / nil stay 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]

ArgsStruct

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,
-- }

πŸ”§ API reference

NetworkManager.Initialize(Util: { Logger: any, Signal: any, Settings: any }) -> ()

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

NetworkManager.Log(params: { Level: string, Message: string, OperationId: string? }) -> ()

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 == true stores into ServerRequestCallbacksAsync
  • 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 ServerCommandEntity traffic and receive the real player
  • on the client, handlers run for server broadcasts and receive nil for player

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)

NetworkManager.UnregisterEntityCommands(entityId: string) -> ()

Server-only helper that removes the entire command table for one entity.

Side effects

  • EntityCommandCallbacks[entityId] = nil
  • logs the removal

NetworkManager.ServerCommandEntity(entityId: string, command: string, ...: any) -> ()

Client-only fire-and-forget entity command.

Transport payload

EntityCommandEvent:FireServer(entityId, command, ...)

Telemetry

  • tracks EntityCommandEvent as Cβ†’S
  • tracked payload includes entityId, command, and all user args

Edge cases

  • if EntityCommandEvent is missing, this silently does nothing
-- client
FSM.Orchestrator:ServerCommandEntity("Door_3", "Open", true)

NetworkManager.BroadcastEntityCommand(entityId: string, command: string, ...: any) -> ()

Server-only broadcast to all clients.

Transport payload

EntityCommandEvent:FireAllClients(entityId, command, ...)

Telemetry

  • tracks EntityCommandEvent as Sβ†’All
-- server
Utility.NetworkManager.BroadcastEntityCommand("Boss_1", "PlayRoar", { Volume = 0.8 })

NetworkManager.BroadcastEntityUpdate(entityId: string, changes: { [string]: any }) -> ()

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 EntityUpdateEvent as Sβ†’All

NetworkManager.ServerRequest(requestName: string, ...: any) -> any?

Client-only synchronous RPC.

Transport payload

OrchestratorFunction:InvokeServer("AppRequestSync", requestName, ...)

Returns

  • handler return value on success
  • nil if the remote is unavailable, the name is unregistered, or the handler throws

Telemetry

  • tracks OrchestratorFunction as Cβ†’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)
end

NetworkManager.ServerRequestAsync(requestName: string, ...: any) -> ()

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

NetworkManager.RegisterEventBus(name: string) -> Signal

Creates or returns a local named Signal.

Side effects

  • if new, stores Signal.new() in EventBuses[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")

NetworkManager.UnregisterEventBus(name: string) -> ()

Destroys and removes a named bus.

Side effects

  • calls bus:Destroy() when the bus has a Destroy method
  • 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()

NetworkManager.GetEventBus(name: string) -> Signal?

Returns the named bus or nil.


NetworkManager.FireEventBus(name: string, ...: any) -> ()

Fires a named bus if it exists.

Side effects

  • increments _busStats[name].Count
  • updates _busStats[name].LastFired
  • appends a recent call with both Args and ArgsStruct
  • 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)

NetworkManager.AwaitEventBus(name: string, timeout: number?) -> Signal?

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 nil when timeout elapses
local bus = FSM.Orchestrator:AwaitEventBus("InventoryChanged", 5)
if bus then
    bus:Connect(function(...) print(...) end)
end

NetworkManager.RecordInboundCall(remoteName: string, direction: string, ...: any) -> ()

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


🧹 Listener lifecycle and cleanup

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.


πŸš€ End-to-end examples

Example 1: Sync profile request

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

Example 2: Fire-and-forget save queue

-- Server
FSM.Orchestrator:RegisterServerRequestCallback("QueueSave", function(player, slotId: string)
    SaveQueue[player.UserId] = slotId
end, true)

-- Client
FSM.Orchestrator:ServerRequestAsync("QueueSave", "slot_a")

Example 3: Entity command round-trip

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

Example 4: Await a bus registration, then subscribe

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)

⚠️ Edge cases and pitfalls

  1. Unregistered request name returns nil. HandleAppRequest simply returns nil when no handler exists.

  2. Async request errors never reach the client. The server wraps handlers in pcall; failures are logged and dropped.

  3. AwaitEventBus does not wait for an event payload. It waits for the bus object to exist.

  4. Client entity-command handlers receive nil for player. Do not write client handlers that expect a Player object.

  5. Telemetry is intentionally lossy. ArgsStruct depth, breadth, and arg count are capped for UI safety.

  6. Remotes can silently no-op when missing. ServerRequest, ServerRequestAsync, ServerCommandEntity, BroadcastEntityCommand, and BroadcastEntityUpdate all check the remote field before firing.

  7. Listener re-registration only cleans tracked RBXScriptConnections. If outside code adds more listeners to the remotes, NetworkManager will not manage those.


πŸ”— See also

🏠 Home


πŸš€ Getting Started


πŸ—οΈ Architecture


πŸ”§ API Reference


πŸ“š Guides


βš™οΈ Advanced


πŸ› οΈ Development


πŸ“– Reference

Clone this wiki locally