How to relay client messages to every player safely
Passing what one client sends straight to FireAllClients multiplies every call by the player count. How to rate-limit, rebuild and batch relayed messages.
What it looks like
A handler takes what one client sends and passes it on to every client with FireAllClients, or to each player in a loop. Typing indicators, look direction, drawings, sprays and effects often work like this. Each call from one client becomes one message to every player in the server, often with the client's own value passed straight through.
remote.OnServerEvent:Connect(function(player, direction) remote:FireAllClients(player, direction) end)
How to detect it
- Find
OnServerEventhandlers that callFireAllClients, orFireClientin a loop overPlayers:GetPlayers(). - Check for a per-player rate limit before the relay. One client can send roughly 500 remote events a second, and the server multiplies each one by the number of players.
- Check what's relayed. Values passed on unchanged, especially tables and strings, reach every other client's code with whatever size and contents the sender chose. The server should rebuild the message from values it has checked.
- Frequent cosmetic updates sent the moment they arrive are better batched: keep the latest value per player and send them together a few times a second.
What an exploiter could do
They can flood every other player with messages, which lags their games, and make every client run its handler for values it never expected. If the relayed value is a table or a long string, one call puts that whole payload on the network once for each player in the server.
How to fix it
- Rate-limit each player before relaying, or keep only their latest value and send it on a fixed timer.
- Validate the client's value and send a new one built on the server, never the client's table or string as it arrived.
- Cap lengths and sizes before anything is relayed.
Vulnerable example
local ReplicatedStorage = game:GetService("ReplicatedStorage") local look = ReplicatedStorage.Remotes.LookDirection look.OnServerEvent:Connect(function(player: Player, direction: unknown) if typeof(direction) ~= "Vector3" then return end look:FireAllClients(player, direction) end)
Fixed example
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local look = ReplicatedStorage.Remotes.LookDirection local latest: { [Player]: Vector3 } = {} look.OnServerEvent:Connect(function(player: Player, direction: unknown) if typeof(direction) ~= "Vector3" then return end local size = direction.Magnitude if not (size > 0 and size < math.huge) then return end latest[player] = direction / size end) Players.PlayerRemoving:Connect(function(player) latest[player] = nil end) while true do task.wait(0.1) local players: { Player } = {} local directions: { Vector3 } = {} for player, direction in latest do table.insert(players, player) table.insert(directions, direction) end table.clear(latest) if #players > 0 then look:FireAllClients(players, directions) end end
Common false positives
- Relays with a per-player cooldown that only send values rebuilt on the server are the fix.
- Messages the server sends on its own schedule, like round timers, aren't relays.
UnreliableRemoteEventdrops messages over 1,000 bytes, so it limits payload size. It doesn't limit how often a client fires, so the rate limit is still needed.