FPS with Stream
Stream sends typed binary packets over UnreliableRemoteEvent — one packed buffer per fire, no JSON. This guide covers a complete FPS position-sync setup: shared schema, server relay with a history buffer, and client movement at 20 Hz. Lag compensation is done manually with the history buffer since there is no built-in rewind.
How data flows
{ userId, cf, health } at 20 Hz via channel:Send(data)channel:On(function(data, player)) — player is the real senderdata.userId with player.UserId (prevents spoofing), appends snapshot to history bufferchannel:SendExcept(player, data) — relays to every other client in one passchannel:On(function(data, _)) and apply positionShared module
Define the schema and channel in one file and require it on both server and client. Both sides must call the same Stream.Channel() definitions before Stream.Init().
-- ReplicatedStorage/StreamChannels.luau (required on BOTH sides)
local RoExpress = require(game.ReplicatedStorage.RoExpress)
local Stream = RoExpress("Stream")
local posSchema = Stream.Schema.New({
{ "userId", "f64" }, -- 8 B: exact for any Roblox UserId
{ "cf", "CFrameLight" }, -- 16 B: position + Y-yaw
{ "health", "u8" }, -- 1 B: 0–255
})
local players = Stream.Channel("players", posSchema, {
maxRate = 30, -- server drops anything above 30 fires/sec per client
})
Stream.Init() -- call once after all Channel() definitions
return { players = players }
CFrameLight is 16 bytes (position + Y-yaw only). Use it for humanoid characters that only rotate horizontally. Use CFrame (28 bytes) if you need full roll/pitch — e.g. vehicles or ragdolls.Server
The server receives each client's position update, overwrites userId to prevent spoofing, records a snapshot for lag compensation, then relays to all other clients.
-- ServerScriptService/MovementServer.server.luau
local ch = require(game.ReplicatedStorage.StreamChannels).players
-- Snapshot history for lag compensation (see section below)
local history: { [Player]: { { t: number, pos: Vector3 } } } = {}
local MAX_SNAPSHOTS = 30 -- ~1s at 30 Hz
ch:On(function(data, player)
-- Always trust the server's knowledge of who sent this
data.userId = player.UserId
-- Record position snapshot for lag comp
if not history[player] then history[player] = {} end
local buf = history[player]
buf[#buf + 1] = { t = os.clock(), pos = data.cf.Position }
if #buf > MAX_SNAPSHOTS then table.remove(buf, 1) end
-- Relay to every other connected client
ch:SendExcept(player, data)
end)
game:GetService("Players").PlayerRemoving:Connect(function(player)
history[player] = nil
end)
Client
The client sends its own position at 20 Hz and subscribes to receive every other player's position as it arrives.
-- StarterPlayerScripts/MovementClient.client.luau
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local LocalPlayer = Players.LocalPlayer
local ch = require(game.ReplicatedStorage.StreamChannels).players
-- Send own state at ~20 Hz using an accumulator
local accum = 0
RunService.Heartbeat:Connect(function(dt)
accum += dt
if accum < 0.05 then return end -- 0.05s = 20 Hz
accum = 0
local char = LocalPlayer.Character
if not char then return end
local hrp = char:FindFirstChild("HumanoidRootPart")
if not hrp then return end
ch:Send({
userId = LocalPlayer.UserId, -- server will override to prevent spoofing
cf = hrp.CFrame,
health = math.floor(char.Humanoid.Health),
})
end)
-- Receive other players' positions
ch:On(function(data, _)
-- data.userId — who this belongs to (set server-side)
-- data.cf — CFrame (position + Y-yaw)
-- data.health — 0-255
local target = Players:GetPlayerByUserId(data.userId)
if not (target and target.Character) then return end
local hrp = target.Character:FindFirstChild("HumanoidRootPart")
if hrp then hrp.CFrame = data.cf end
end)
channel:On is a Player on the server and nil on the client. The underscore _ above is idiomatic Luau for an ignored parameter.Lag compensation
Stream does not have a built-in rewind. Store snapshots manually (the server already does this above) and search the buffer for the closest timestamp when a hit claim arrives.
-- inside the same server script, using the history table from above
local app = RoExpress("App")
app:Post("combat/hit", function(req, res)
local ts = req.data.timestamp -- os.clock() value from client at fire
local targetId = req.data.targetId
local target = game:GetService("Players"):GetPlayerByUserId(targetId)
if not target then res:Status(400):Error("Unknown target"); return end
local buf = history[target]
if not buf or #buf == 0 then
res:Status(400):Error("No position history"); return
end
-- Find the snapshot closest to when the client fired
local best, bestDt = nil, math.huge
for _, snap in ipairs(buf) do
local dt = math.abs(snap.t - ts)
if dt < bestDt then best, bestDt = snap, dt end
end
if bestDt > 0.5 then
res:Status(400):Error("Timestamp too old"); return
end
local claimPos = req.data.hitPosition
if (best.pos - claimPos).Magnitude > 8 then
res:Status(400):Error("Position mismatch"); return
end
-- Valid hit — apply damage
DamageService:Apply(targetId, req.data.damage)
res:Send({ confirmed = true })
end)
os.clock() is not synchronized between server and client. A production lag-comp implementation should measure round-trip time and apply a clock offset. This example illustrates the snapshot lookup pattern; clock sync is outside the scope of Stream.See also
Stream | full API reference · Stream: Server Side | all six send methods, rate limiting, delta · Stream: Client Side | throttling sends, unsubscribing · App | combat/hit route · Research Papers | Bernier lag compensation algorithm · Tamper Guide | combining exploit detection with lag compensation