Use this file to discover all available pages before exploring further.
Roblox’s client-server model requires explicit messaging for any information that must cross the network boundary. The two primary tools are RemoteEvent (fire-and-forget) and RemoteFunction (request/response). Using them correctly — and sparingly — is the difference between a smooth multiplayer game and one plagued by lag, exploits, and dropped state.
Fire-and-forget. No return value. Can fire from server → client, client → server, or server → all clients. Use for actions where you don’t need confirmation: damage events, UI updates, sound triggers, score changes.
RemoteFunction
Request/response. The caller yields until the receiver returns a value. Use only when the caller genuinely needs a result before continuing — e.g., validating a purchase. Never use on the server to call a client; a disconnected or exploiting client will stall the server forever.
Decision criteria:
Situation
Use
Server tells client to update UI
RemoteEvent:FireClient
Client tells server an action occurred
RemoteEvent:FireServer
Server broadcasts to all clients
RemoteEvent:FireAllClients
Client needs a validated result back (e.g., price lookup)
The server fires an event to a specific client to update that player’s UI, trigger an effect, or push state.
-- Server: ServerScriptService/GameManager.luaulocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Players = game:GetService("Players")-- Create remotes once on the server; clients wait for themlocal Remotes = Instance.new("Folder")Remotes.Name = "Remotes"Remotes.Parent = ReplicatedStoragelocal RoundStateEvent = Instance.new("RemoteEvent")RoundStateEvent.Name = "RoundState"RoundStateEvent.Parent = Remoteslocal ScoreUpdateEvent = Instance.new("RemoteEvent")ScoreUpdateEvent.Name = "ScoreUpdate"ScoreUpdateEvent.Parent = Remotes-- Fire round state to one playerlocal function notifyPlayer(player: Player, state: string, data: { [string]: any }) RoundStateEvent:FireClient(player, state, data)end-- Fire score update to all playerslocal function broadcastScores(scores: { [string]: number }) for _, player in Players:GetPlayers() do ScoreUpdateEvent:FireClient(player, scores) endend
-- Client: StarterPlayerScripts/RoundHud.client.luaulocal ReplicatedStorage = game:GetService("ReplicatedStorage")local remotes = ReplicatedStorage:WaitForChild("Remotes")local roundStateEvent = remotes:WaitForChild("RoundState")local scoreUpdateEvent = remotes:WaitForChild("ScoreUpdate")roundStateEvent.OnClientEvent:Connect(function(state: string, data: { [string]: any }) if state == "Playing" then -- Show round timer, etc. print("Round started! Duration:", data.duration) elseif state == "Results" then print("Winner:", data.winningTeam) endend)scoreUpdateEvent.OnClientEvent:Connect(function(scores: { [string]: number }) -- Update scoreboard UI for name, score in scores do print(name, score) endend)
The client fires an event to request that the server perform an action. The server always validates before acting.
-- Client: StarterPlayerScripts/PlayerActions.client.luaulocal ReplicatedStorage = game:GetService("ReplicatedStorage")local UserInputService = game:GetService("UserInputService")local remotes = ReplicatedStorage:WaitForChild("Remotes")local readyUpEvent = remotes:WaitForChild("ReadyUp")local equipEvent = remotes:WaitForChild("EquipItem")-- Ready-up button fires a server eventreadyUpEvent:FireServer()-- Item equip fires with an item ID (server validates ownership)local function equipItem(itemId: string) equipEvent:FireServer(itemId)end
-- Server: ServerScriptService/RemoteHandlers/EquipHandler.server.luaulocal ReplicatedStorage = game:GetService("ReplicatedStorage")local remotes = ReplicatedStorage:WaitForChild("Remotes")local equipEvent = remotes:WaitForChild("EquipItem")equipEvent.OnServerEvent:Connect(function(player: Player, itemId: string) -- Always validate arguments server-side if typeof(itemId) ~= "string" then return end if #itemId > 64 then return end -- Check ownership in server-side inventory local inventory = player:FindFirstChild("Inventory") if not inventory or not inventory:FindFirstChild(itemId) then return end -- Perform equip logic... print(player.Name, "equipped", itemId)end)
Use FireAllClients sparingly — it sends a packet to every connected player simultaneously.
-- Server broadcast patternlocal AnnouncementEvent = Instance.new("RemoteEvent")AnnouncementEvent.Name = "Announcement"AnnouncementEvent.Parent = ReplicatedStoragelocal function broadcastAnnouncement(message: string) AnnouncementEvent:FireAllClients(message)end-- Trigger during a special eventbroadcastAnnouncement("Double XP weekend has started!")
Every remote handler on the server must enforce rate limits. A client firing a remote 1,000 times per second is either broken or malicious — either way, the server should reject it.
-- ServerScriptService/Modules/RateLimiter.luaulocal Players = game:GetService("Players")local RateLimiter = {}export type RateLimitConfig = { maxRequests: number, -- max requests in the window windowSeconds: number, -- time window in seconds cooldownSeconds: number?, -- optional cooldown after hitting the limit kickAfterViolations: number?, -- kick after this many limit hits kickMessage: string?,}type PlayerActionData = { timestamps: { number }, violations: number, cooldownUntil: number,}local tracking: { [Player]: { [string]: PlayerActionData } } = {}local DEFAULT_KICK_MESSAGE = "Too many requests. Please try again later."function RateLimiter.check(player: Player, action: string, config: RateLimitConfig): boolean local now = os.clock() if not tracking[player] then tracking[player] = {} end local playerActions = tracking[player] if not playerActions[action] then playerActions[action] = { timestamps = {}, violations = 0, cooldownUntil = 0 } end local data = playerActions[action] -- Check cooldown if now < data.cooldownUntil then return false end -- Prune timestamps outside the window local windowStart = now - config.windowSeconds local pruned = {} for _, ts in data.timestamps do if ts > windowStart then table.insert(pruned, ts) end end data.timestamps = pruned -- Check if at the limit if #data.timestamps >= config.maxRequests then data.violations += 1 if config.cooldownSeconds then data.cooldownUntil = now + config.cooldownSeconds end if config.kickAfterViolations and data.violations >= config.kickAfterViolations then local message = config.kickMessage or DEFAULT_KICK_MESSAGE warn(`[RateLimiter] Kicking {player.Name}: {data.violations} violations on "{action}"`) task.defer(function() player:Kick(message) end) else warn(`[RateLimiter] {player.Name}: rate limited on "{action}" (violation #{data.violations})`) end return false end table.insert(data.timestamps, now) return trueend-- Convenience: wrap a RemoteEvent with rate limitingfunction RateLimiter.wrapRemote( remote: RemoteEvent, config: RateLimitConfig, handler: (player: Player, ...any) -> ()) remote.OnServerEvent:Connect(function(player: Player, ...: any) if not RateLimiter.check(player, remote.Name, config) then return end handler(player, ...) end)endPlayers.PlayerRemoving:Connect(function(player) tracking[player] = nilend)return RateLimiter
The server is the source of truth for who is ready. Clients only send toggle requests.
-- Server: LobbyManagerlocal readyPlayers: { [Player]: boolean } = {}local function getReadyCount(): number local count = 0 for player, isReady in readyPlayers do if isReady and player.Parent == Players then count += 1 end end return countendlocal function broadcastStatus(message: string) for _, player in Players:GetPlayers() do LobbyStatusEvent:FireClient(player, message, getReadyCount(), #Players:GetPlayers()) endendReadyUpEvent.OnServerEvent:Connect(function(player: Player) readyPlayers[player] = not readyPlayers[player] broadcastStatus( if readyPlayers[player] then `{player.Name} is ready!` else `{player.Name} unreadied.` )end)
A clean state machine broadcast pattern keeps all clients in sync:
-- Server broadcasts state transitionslocal function broadcastRoundState(state: string, data: { [string]: any }?) for _, player in Players:GetPlayers() do RoundStateEvent:FireClient(player, state, data or {}) endend-- State transitionsbroadcastRoundState("Intermission", { duration = 15, nextMap = "Desert" })broadcastRoundState("Countdown", { timeLeft = 5 })broadcastRoundState("Playing", { duration = 120 })broadcastRoundState("Results", { winningTeam = "Red", topPlayer = "PlayerName" })
-- Client reacts to each stateRoundStateEvent.OnClientEvent:Connect(function(state: string, data: { [string]: any }) if state == "Intermission" then statusLabel.Text = `INTERMISSION | Next: {data.nextMap} | {data.timeLeft or data.duration}s` elseif state == "Countdown" then statusLabel.Text = `GET READY! {data.timeLeft}` elseif state == "Playing" then statusLabel.Text = `ROUND | {data.timeLeft or data.duration}s` elseif state == "Results" then statusLabel.Text = `{data.winningTeam} wins! MVP: {data.topPlayer}` endend)
Send one table instead of three separate FireClient calls per player per frame.
-- BADremoteHealth:FireClient(player, health)remoteAmmo:FireClient(player, ammo)remoteStamina:FireClient(player, stamina)-- GOODremotePlayerState:FireClient(player, { health = health, ammo = ammo, stamina = stamina,})
Use UnreliableRemoteEvent for position sync
High-frequency, non-critical data (NPC positions, rotation) can use UnreliableRemoteEvent. Dropped packets are fine because the next update corrects state.
local posSync = ReplicatedStorage :WaitForChild("PositionSync")-- fires every heartbeat, drops OKposSync:FireClient(player, npcPositions)
Delta compression
Send only values that changed since the last update, not the full state table every tick.
Short key names
hp instead of hitPoints in remote payloads — reduces payload size across thousands of calls per minute.
Keep RemoteEvent calls under 50 per second per client. Profile with the F9 Developer Console > Stats tab: watch the “Data Send” and “Data Receive” rates while playing.
Never fire a RemoteEvent from a client-side loop without throttling. A tight loop can flood the server with thousands of requests per second, causing lag for everyone.
-- BAD: fires every render step (60+/sec) with no throttleRunService.RenderStepped:Connect(function() updateRemote:FireServer(position)end)-- GOOD: throttled to once per 0.1 secondslocal lastFire = 0RunService.RenderStepped:Connect(function() local now = os.clock() if now - lastFire >= 0.1 then lastFire = now updateRemote:FireServer(position) endend)
Unvalidated remote payloads
Every argument in every OnServerEvent callback is attacker-controlled. Always type-check, range-check, and authorization-check before acting.
-- BAD: no validationDamageRemote.OnServerEvent:Connect(function(player, targetName, damage) local target = Players:FindFirstChild(targetName) target.Character.Humanoid:TakeDamage(damage) -- exploiter sends 99999end)-- GOOD: full validation chainDamageRemote.OnServerEvent:Connect(function(player, targetName, damage) if typeof(targetName) ~= "string" then return end if typeof(damage) ~= "number" or damage < 1 or damage > 50 then return end -- ... range check, cooldown check, etc.end)
Using RemoteFunction to call clients from the server
If a client disconnects while RemoteFunction:InvokeClient is waiting, the server thread yields forever (until the server restarts). Use RemoteEvent with a callback pattern instead, or wrap InvokeClient in a coroutine with a timeout.
Not saving data before TeleportAsync
When a player teleports between places, PlayerRemoving fires but DataStore saves may not complete before the server hop begins. Always save explicitly and wait for confirmation before calling TeleportService:TeleportAsync.
-- GOOD: save then teleportDataManager.savePlayerData(player)task.wait(0.5)pcall(function() TeleportService:TeleportAsync(arenaPlaceId, { player })end)
For real-time communication between servers (global announcements, cross-server events), use MessagingService.
local MessagingService = game:GetService("MessagingService")local TOPIC = "GlobalAnnouncements"-- Subscribe on server startuplocal success, subscription = pcall(function() return MessagingService:SubscribeAsync(TOPIC, function(message) local data = message.Data -- Broadcast to all players on this server for _, player in Players:GetPlayers() do AnnouncementEvent:FireClient(player, data.text) end end)end)-- Publish from any serverlocal function announce(text: string) pcall(function() MessagingService:PublishAsync(TOPIC, { text = text, serverId = game.JobId, timestamp = os.time(), }) end)end