Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

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.

RemoteEvent vs RemoteFunction

RemoteEvent

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:
SituationUse
Server tells client to update UIRemoteEvent:FireClient
Client tells server an action occurredRemoteEvent:FireServer
Server broadcasts to all clientsRemoteEvent:FireAllClients
Client needs a validated result back (e.g., price lookup)RemoteFunction:InvokeServer
Server calling client — avoidNever use RemoteFunction:InvokeClient

Core Communication Patterns

Server → Client

The server fires an event to a specific client to update that player’s UI, trigger an effect, or push state.
-- Server: ServerScriptService/GameManager.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- Create remotes once on the server; clients wait for them
local Remotes = Instance.new("Folder")
Remotes.Name = "Remotes"
Remotes.Parent = ReplicatedStorage

local RoundStateEvent = Instance.new("RemoteEvent")
RoundStateEvent.Name = "RoundState"
RoundStateEvent.Parent = Remotes

local ScoreUpdateEvent = Instance.new("RemoteEvent")
ScoreUpdateEvent.Name = "ScoreUpdate"
ScoreUpdateEvent.Parent = Remotes

-- Fire round state to one player
local function notifyPlayer(player: Player, state: string, data: { [string]: any })
    RoundStateEvent:FireClient(player, state, data)
end

-- Fire score update to all players
local function broadcastScores(scores: { [string]: number })
    for _, player in Players:GetPlayers() do
        ScoreUpdateEvent:FireClient(player, scores)
    end
end
-- Client: StarterPlayerScripts/RoundHud.client.luau
local 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)
    end
end)

scoreUpdateEvent.OnClientEvent:Connect(function(scores: { [string]: number })
    -- Update scoreboard UI
    for name, score in scores do
        print(name, score)
    end
end)

Client → Server

The client fires an event to request that the server perform an action. The server always validates before acting.
-- Client: StarterPlayerScripts/PlayerActions.client.luau
local 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 event
readyUpEvent: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.luau
local 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)

Server → All Clients

Use FireAllClients sparingly — it sends a packet to every connected player simultaneously.
-- Server broadcast pattern
local AnnouncementEvent = Instance.new("RemoteEvent")
AnnouncementEvent.Name = "Announcement"
AnnouncementEvent.Parent = ReplicatedStorage

local function broadcastAnnouncement(message: string)
    AnnouncementEvent:FireAllClients(message)
end

-- Trigger during a special event
broadcastAnnouncement("Double XP weekend has started!")

Rate Limiting Implementation

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.luau

local 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 true
end

-- Convenience: wrap a RemoteEvent with rate limiting
function 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)
end

Players.PlayerRemoving:Connect(function(player)
    tracking[player] = nil
end)

return RateLimiter

Applying Rate Limits

local RateLimiter = require(ServerScriptService.Modules.RateLimiter)

-- Shoot remote: 10 shots per 5 seconds, kick after 3 violations
RateLimiter.wrapRemote(ShootRemote, {
    maxRequests = 10,
    windowSeconds = 5,
    cooldownSeconds = 2,
    kickAfterViolations = 3,
    kickMessage = "Firing too fast. Disconnected.",
}, function(player, ...)
    handleShoot(player, ...)
end)

-- Chat: 5 messages per 10 seconds
ChatRemote.OnServerEvent:Connect(function(player, message)
    if not RateLimiter.check(player, "Chat", {
        maxRequests = 5,
        windowSeconds = 10,
        cooldownSeconds = 5,
        kickAfterViolations = 5,
    }) then
        return
    end
    processChat(player, message)
end)

State Synchronization Patterns

Lobby Ready-Up State

The server is the source of truth for who is ready. Clients only send toggle requests.
-- Server: LobbyManager
local 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 count
end

local function broadcastStatus(message: string)
    for _, player in Players:GetPlayers() do
        LobbyStatusEvent:FireClient(player, message, getReadyCount(), #Players:GetPlayers())
    end
end

ReadyUpEvent.OnServerEvent:Connect(function(player: Player)
    readyPlayers[player] = not readyPlayers[player]
    broadcastStatus(
        if readyPlayers[player] then `{player.Name} is ready!` else `{player.Name} unreadied.`
    )
end)

Round State Machine

A clean state machine broadcast pattern keeps all clients in sync:
-- Server broadcasts state transitions
local function broadcastRoundState(state: string, data: { [string]: any }?)
    for _, player in Players:GetPlayers() do
        RoundStateEvent:FireClient(player, state, data or {})
    end
end

-- State transitions
broadcastRoundState("Intermission", { duration = 15, nextMap = "Desert" })
broadcastRoundState("Countdown",   { timeLeft = 5 })
broadcastRoundState("Playing",     { duration = 120 })
broadcastRoundState("Results",     { winningTeam = "Red", topPlayer = "PlayerName" })
-- Client reacts to each state
RoundStateEvent.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}`
    end
end)

Network Optimization Tips

Batch related remotes

Send one table instead of three separate FireClient calls per player per frame.
-- BAD
remoteHealth:FireClient(player, health)
remoteAmmo:FireClient(player, ammo)
remoteStamina:FireClient(player, stamina)

-- GOOD
remotePlayerState: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 OK
posSync: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.

Common Pitfalls

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 throttle
RunService.RenderStepped:Connect(function()
    updateRemote:FireServer(position)
end)

-- GOOD: throttled to once per 0.1 seconds
local lastFire = 0
RunService.RenderStepped:Connect(function()
    local now = os.clock()
    if now - lastFire >= 0.1 then
        lastFire = now
        updateRemote:FireServer(position)
    end
end)
Every argument in every OnServerEvent callback is attacker-controlled. Always type-check, range-check, and authorization-check before acting.
-- BAD: no validation
DamageRemote.OnServerEvent:Connect(function(player, targetName, damage)
    local target = Players:FindFirstChild(targetName)
    target.Character.Humanoid:TakeDamage(damage) -- exploiter sends 99999
end)

-- GOOD: full validation chain
DamageRemote.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)
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.
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 teleport
DataManager.savePlayerData(player)
task.wait(0.5)
pcall(function()
    TeleportService:TeleportAsync(arenaPlaceId, { player })
end)

MessagingService: Cross-Server Pub/Sub

For real-time communication between servers (global announcements, cross-server events), use MessagingService.
local MessagingService = game:GetService("MessagingService")
local TOPIC = "GlobalAnnouncements"

-- Subscribe on server startup
local 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 server
local function announce(text: string)
    pcall(function()
        MessagingService:PublishAsync(TOPIC, {
            text = text,
            serverId = game.JobId,
            timestamp = os.time(),
        })
    end)
end
MessagingService limits:
LimitValue
Message size1 KB
PublishAsync per minute150 + 60 × numPlayers
SubscribeAsync per server5 + 2 × numPlayers
PersistenceNone — only online servers receive messages

Build docs developers (and LLMs) love