Guide

Stream: Client Side

Everything Stream does on the client — defining channels to match the server, waiting for the server's remotes, sending your own state, throttling sends to a target rate, and subscribing to receive world state.

Read this alongside the server guide. Both sides define the same channels before calling Stream.Init(). See Stream: Server Side for the server half.

How the client connects

When the client calls Stream.Init(), Stream calls WaitForChild on the server's RoExpressStream folder (10-second timeout). This means:

  • The server must call Stream.Init() before the client does.
  • The client will yield until the server's remotes appear — no manual waiting needed.
  • If the server folder does not appear within 10 seconds, Stream errors with a descriptive message.

Channel definitions must match the server

Both sides must call Stream.Channel() with the same name and schema for every channel. Channel IDs are derived from the name hash — if the names match, the IDs match, regardless of the order the definitions appear in your code.

The cleanest pattern is a shared ModuleScript in ReplicatedStorage that both the server and client require:

-- ReplicatedStorage/StreamChannels.luau  (shared — required by both sides)
local RoExpress = require(game.ReplicatedStorage.RoExpress)
local Stream    = RoExpress("Stream")

local moveSchema = Stream.Schema.New({
    { "userId", "f64"         },
    { "cf",     "CFrameLight" },
    { "health", "u8"          },
})

local move = Stream.Channel("playerMove", moveSchema, { maxRate = 30 })

Stream.Init()

return { move = move }
Stream.Init() in a shared module. When a shared module calls Stream.Init(), it runs once on each side (server and client) at require time. On the server it creates the remotes; on the client it waits for them. This is the recommended pattern.

Sending to the server

channel:Send(data) is the only send method available on the client. Calling any server-only method (SendTo, Broadcast, etc.) from a client will error.

local move = require(game.ReplicatedStorage.StreamChannels).move

move:Send({
    userId = game.Players.LocalPlayer.UserId,
    cf     = hrp.CFrame,
    health = math.floor(humanoid.Health),
})

All schema fields are required. Pack will error if any field is nil.

Throttling sends

Stream has no built-in send throttle on the client — it fires exactly when you call Send. Use a Heartbeat accumulator to target a specific tick rate:

local RunService = game:GetService("RunService")
local accum = 0

RunService.Heartbeat:Connect(function(dt)
    accum += dt
    if accum < 0.05 then return end  -- 0.05 s = 20 Hz
    accum = 0

    local char = game.Players.LocalPlayer.Character
    if not char then return end
    local hrp = char:FindFirstChild("HumanoidRootPart")
    if not hrp then return end

    move:Send({
        userId = game.Players.LocalPlayer.UserId,
        cf     = hrp.CFrame,
        health = math.floor(char.Humanoid.Health),
    })
end)
Target rateAccumulator thresholdUse case
60 Hz0.0167Vehicles, physics objects that need smooth replication
30 Hz0.0333Player characters — good balance of fidelity vs bandwidth
20 Hz0.05Default for most FPS movement
10 Hz0.1Slow-moving objects, ambient state like NPC health

Receiving from the server

channel:On works identically on the client and server. The difference is the second argument: on the client, sender is always nil — Stream has no way to identify which server-side source produced a packet.

-- Persistent subscription
local unsub = move:On(function(data, _)
    -- data.userId, data.cf, data.health
    -- _ is nil on the client — using _ signals we know and don't care
    local player = game.Players:GetPlayerByUserId(data.userId)
    if player and player.Character then
        local hrp = player.Character:FindFirstChild("HumanoidRootPart")
        if hrp then hrp.CFrame = data.cf end
    end
end)

-- Stop listening when done (e.g. when a UI closes)
unsub()

One-shot receives with Once

Use channel:Once when you need exactly one packet — for example, an initial world-state snapshot sent when the player joins.

-- Auto-unsubscribes after the first packet arrives
move:Once(function(data, _)
    print("Initial world state received", data)
end)

-- Or capture the unsubscribe function to cancel it early if needed
local unsub = move:Once(function(data, _) end)
unsub()  -- cancel before first fire

Reliable vs unreliable on the client

The channel's reliable option is set at definition time and applies equally to both sides. A reliable channel uses RemoteEvent on both the client send path and the receive path.

Unreliable (default)Reliable
TransportUnreliableRemoteEventRemoteEvent
DeliveryBest-effort, may dropGuaranteed, ordered
LatencyLowerHigher (retransmit on loss)
Good forPosition, rotation, healthChat, inventory, ability activations

See also

Stream | full API reference  ·  Stream: Server Side | all send methods, rate limiting, delta  ·  FPS Guide | end-to-end with lag compensation  ·  Live Streaming Example | complete movement replication  ·  Stream Benchmarks | bandwidth measurements