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.
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(), 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 rate | Accumulator threshold | Use case |
|---|---|---|
| 60 Hz | 0.0167 | Vehicles, physics objects that need smooth replication |
| 30 Hz | 0.0333 | Player characters — good balance of fidelity vs bandwidth |
| 20 Hz | 0.05 | Default for most FPS movement |
| 10 Hz | 0.1 | Slow-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 | |
|---|---|---|
| Transport | UnreliableRemoteEvent | RemoteEvent |
| Delivery | Best-effort, may drop | Guaranteed, ordered |
| Latency | Lower | Higher (retransmit on loss) |
| Good for | Position, rotation, health | Chat, 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