Guide

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.

Two separate pages exist for deeper coverage. This guide shows the full picture end-to-end. For a focused breakdown of each side see Stream: Server Side and Stream: Client Side.

How data flows

1
Client sends { userId, cf, health } at 20 Hz via channel:Send(data)
2
Server receives via channel:On(function(data, player))player is the real sender
3
Server overwrites data.userId with player.UserId (prevents spoofing), appends snapshot to history buffer
4
Server calls channel:SendExcept(player, data) — relays to every other client in one pass
5
Other clients receive via channel:On(function(data, _)) and apply position

Shared 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 vs CFrame. 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)
Sender is always nil on the client. The second argument to 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)
Client clock vs server clock. 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