Roblox has three script types, each with a distinct execution context. Understanding where and how each runs is the foundation of everything else.
-- ModuleScript (ReplicatedStorage > SharedConfig) return { GameName = "Combat Arena", MaxPlayers = 16, RespawnTime = 3, StartingGold = 100, WeaponData = { Sword = { Damage = 25, Range = 8, Cooldown = 0.8 }, Gun = { Damage = 15, Range = 200, Cooldown = 0.3 }, } } -- Server Script (ServerScriptService > GameManager) local Config = require(game.ReplicatedStorage.SharedConfig) print("Server loaded config:", Config.GameName) -- LocalScript (StarterPlayer > StarterPlayerScripts > UIController) local Config = require(game.ReplicatedStorage.SharedConfig) print("Client loaded config:", Config.GameName)
RemoteEvent is the primary communication channel between server and client. It is a one-directional fire-and-forget message — no return value, no blocking. Always parent RemoteEvents to ReplicatedStorage so both sides can access them.
Player presses a key on their client → fires server → server validates, applies damage, broadcasts result to all clients.
-- RemoteEvent in ReplicatedStorage named "PunchEvent" -- LocalScript (StarterCharacterScripts > PunchHandler) local UserInputService = game:GetService("UserInputService") local PunchEvent = game.ReplicatedStorage.PunchEvent local Players = game:GetService("Players") local player = Players.LocalPlayer UserInputService.InputBegan:Connect(function(input, gameProcessed) if gameProcessed then return end if input.KeyCode == Enum.KeyCode.F then PunchEvent:FireServer() end end) -- Script (ServerScriptService > PunchHandler) local PunchEvent = game.ReplicatedStorage.PunchEvent local Players = game:GetService("Players") PunchEvent.OnServerEvent:Connect(function(player) -- Server validates: is the player alive? Do they have a character? local character = player.Character if not character or not character:FindFirstChild("Humanoid") then return end -- Find nearest target within 12 studs local root = character:FindFirstChild("HumanoidRootPart") if not root then return end local closestPlayer, closestDist = nil, 12 for _, otherPlayer in Players:GetPlayers() do if otherPlayer == player then continue end local otherChar = otherPlayer.Character if not otherChar then continue end local otherRoot = otherChar:FindFirstChild("HumanoidRootPart") if not otherRoot then continue end local dist = (root.Position - otherRoot.Position).Magnitude if dist < closestDist then closestDist = dist closestPlayer = otherPlayer end end if closestPlayer then local targetHumanoid = closestPlayer.Character.Humanoid targetHumanoid:TakeDamage(10) -- Broadcast punch animation to all clients PunchEvent:FireAllClients(player, closestPlayer) end end)
RemoteFunction enables a request-response pattern. The caller blocks until the receiver returns a value. This is fundamentally different from RemoteEvent.
-- RemoteFunction in ReplicatedStorage named "RequestDataFunction" -- Server Script (ServerScriptService > DataProvider) local RequestDataFunction = game.ReplicatedStorage.RequestDataFunction RequestDataFunction.OnServerInvoke = function(player, dataType) -- Never trust the client's request fully, but dataType is a simple string if dataType == "inventory" then return GetPlayerInventory(player) -- returns table elseif dataType == "stats" then return GetPlayerStats(player) end return nil end -- LocalScript (StarterPlayerScripts > DataRequester) local RequestDataFunction = game.ReplicatedStorage.RequestDataFunction -- WARNING: This blocks the client thread until the server responds local success, result = pcall(function() return RequestDataFunction:InvokeServer("inventory") end) if success then print("Inventory received:", result) else warn("Failed to fetch inventory:", result) end
BindableEvent and BindableFunction are for intra-context communication. They do not cross the network — server scripts use them to talk to other server scripts, and client scripts use them to talk to other client scripts.
-- BindableEvent in ReplicatedStorage named "ServerEventBus" -- ModuleScript wrapper (ReplicatedStorage > EventBus) local EventBus = {} EventBus.__index = EventBus function EventBus.new() local self = setmetatable({}, EventBus) self._bindable = Instance.new("BindableEvent") return self end function EventBus:Fire(...) self._bindable:Fire(...) end function EventBus:Connect(callback) return self._bindable.Event:Connect(callback) end return EventBus -- Usage: CombatSystem.server.lua local EventBus = require(game.ReplicatedStorage.EventBus) local bus = EventBus.new() game:GetService("ReplicatedStorage").CombatBus.Value = bus._bindable -- Some other server system bus:Connect(function(player, damage) print(player.Name .. " took " .. damage .. " damage") end) -- Fire from anywhere on the server bus:Fire(player, 25)
This is the single most important rule in Roblox development. The client is in the player's hands. Players can and will modify their client. Exploiters use tools like Synapse, Krnl, Script-Ware, and countless others to execute arbitrary Luau, fire remotes with forged arguments, teleport, noclip, and modify game state.
-- INSECURE: Server trusts client-provided damage value RemoteEvent.OnServerEvent:Connect(function(player, target, damageAmount) -- Exploiter can fire with damageAmount = 99999 target.Humanoid:TakeDamage(damageAmount) end) -- SECURE: Server validates everything independently local cooldowns = {} RemoteEvent.OnServerEvent:Connect(function(player, targetPlayer) -- 1. Validate player has character local char = player.Character if not char then return end -- 2. Check cooldown local now = os.clock() if cooldowns[player] and now - cooldowns[player] < 1 then return end cooldowns[player] = now -- 3. Validate target exists and is within range local targetChar = targetPlayer.Character if not targetChar then return end local root = char:FindFirstChild("HumanoidRootPart") local targetRoot = targetChar:FindFirstChild("HumanoidRootPart") if not root or not targetRoot then return end local dist = (root.Position - targetRoot.Position).Magnitude if dist > 12 then return end -- Out of range, ignore -- 4. Server-authoritative damage calculation local baseDamage = 10 local weapon = char:FindFirstChildOfClass("Tool") if weapon and weapon:FindFirstChild("Damage") then baseDamage = weapon.Damage.Value end targetChar.Humanoid:TakeDamage(baseDamage) print(`{player.Name} hit {targetPlayer.Name} for {baseDamage} damage`) end)
FilteringEnabled has been mandatory since 2017. There is no way to disable it. This means clients cannot replicate changes to other clients directly. The server is the only authority that can replicate state changes.
-- Server Script: Creating a part replicates to all clients local part = Instance.new("Part") part.Size = Vector3.new(4, 1, 4) part.BrickColor = BrickColor.new("Bright red") part.Position = Vector3.new(0, 10, 0) part.Anchored = true part.Parent = workspace -- Setting an Attribute that replicates to all clients part:SetAttribute("Health", 100) part:SetAttribute("IsDestroyable", true) -- Client LocalScript: Reading the replicated part local part = workspace:WaitForChild("Part") print(part.BrickColor.Name) -- "Bright red" print(part:GetAttribute("Health")) -- 100 -- Client can read, but setting these will NOT replicate to other clients part.BrickColor = BrickColor.new("Bright blue") -- Only this client sees blue
Every player who joins has a Player object created by the server. The character model is loaded into the Workspace. Understanding the character lifecycle is key to building any multiplayer game.
-- Server Script (ServerScriptService > PlayerSetup) local Players = game:GetService("Players") local function OnPlayerAdded(player) print(`{player.Name} joined the game (UserId: {player.UserId})`) -- Wait for character to load player.CharacterAdded:Connect(function(character) print(`{player.Name}'s character loaded`) local humanoid = character:WaitForChild("Humanoid") local rootPart = character:WaitForChild("HumanoidRootPart") -- Teleport to spawn location local spawnLocation = workspace:FindFirstChild("SpawnLocation") if spawnLocation then rootPart.Position = spawnLocation.Position + Vector3.new(0, 3, 0) end -- Set up player data / leaderstats SetupLeaderstats(player) -- Equip starter tools local starterTools = game.ServerStorage:FindFirstChild("StarterTools") if starterTools then for _, tool in starterTools:GetChildren() do tool:Clone().Parent = player.Backpack end end end) -- Handle character removal (death) player.CharacterRemoving:Connect(function(character) print(`{player.Name}'s character being removed`) end) -- Handle player leaving player.AncestryChanged:Connect(function(child, parent) if not parent then print(`{player.Name} left the game`) -- Save data here SavePlayerData(player) end end) end -- Connect for existing and future players for _, player in Players:GetPlayers() do OnPlayerAdded(player) end Players.PlayerAdded:Connect(OnPlayerAdded)
leaderstats is a Roblox convention for a folder that automatically displays in the game's leaderboard (Tab menu). Create a Folder named "leaderstats" inside the player, with IntValue, StringValue, or NumberValue children. Their names become column headers, and their values display to all players in the lobby.
-- Server Script (ServerScriptService > LeaderstatsManager) local Players = game:GetService("Players") local function SetupLeaderstats(player) -- Remove existing if any (safety) local existing = player:FindFirstChild("leaderstats") if existing then existing:Destroy() end local leaderstats = Instance.new("Folder") leaderstats.Name = "leaderstats" leaderstats.Parent = player local gold = Instance.new("IntValue") gold.Name = "Gold" gold.Value = 100 gold.Parent = leaderstats local level = Instance.new("IntValue") level.Name = "Level" level.Value = 1 level.Parent = leaderstats local kills = Instance.new("IntValue") kills.Name = "Kills" kills.Value = 0 kills.Parent = leaderstats local status = Instance.new("StringValue") status.Name = "Status" status.Value = "Alive" status.Parent = leaderstats end -- Add gold (server-authoritative) local function AddGold(player, amount) local leaderstats = player:FindFirstChild("leaderstats") if not leaderstats then return end local gold = leaderstats:FindFirstChild("Gold") if gold then gold.Value += amount -- Replicates automatically end end Players.PlayerAdded:Connect(SetupLeaderstats)
Tool is a Roblox object that players can equip from their Backpack. Tools handle equipping, unequipping, and activation automatically. Every Tool has a Handle (a Part) used for physics and raycasting.
-- LocalScript inside a Tool (StarterPack > Sword > LocalScript) local tool = script.Parent local RemoteEvent = game.ReplicatedStorage.PunchEvent -- weapon hit event local Players = game:GetService("Players") local player = Players.LocalPlayer local cooldown = false tool.Activated:Connect(function() if cooldown then return end cooldown = true -- Fire server to process hit RemoteEvent:FireServer(tool) -- Play swing animation locally (client prediction) local humanoid = player.Character.Humanoid local animator = humanoid:FindFirstChildOfClass("Animator") if animator and tool:FindFirstChild("SwingAnim") then local animTrack = animator:LoadAnimation(tool.SwingAnim) animTrack:Play() end task.wait(1) -- Swing cooldown cooldown = false end) -- Server Script (ServerScriptService > CombatHandler) local RemoteEvent = game.ReplicatedStorage.PunchEvent RemoteEvent.OnServerEvent:Connect(function(player, tool) local character = player.Character if not character then return end -- Validate tool is actually equipped by this player if tool.Parent ~= character then return end local handle = tool:FindFirstChild("Handle") local damageValue = tool:FindFirstChild("Damage") if not handle or not damageValue then return end local damage = damageValue.Value local range = tool:FindFirstChild("Range") and tool.Range.Value or 8 -- Raycast from handle forward local origin = handle.Position local direction = handle.CFrame.LookVector * range local rayResult = workspace:Raycast(origin, direction) if rayResult and rayResult.Instance then local hitChar = rayResult.Instance:FindFirstAncestorOfClass("Model") if hitChar and hitChar:FindFirstChild("Humanoid") then local targetHumanoid = hitChar.Humanoid targetHumanoid:TakeDamage(damage) -- Broadcast hit effect RemoteEvent:FireAllClients(player, hitChar, damage) end end end)
Weapon hit detection must be server-authoritative. The client can predict the shot for responsiveness, but the server must validate and apply damage. Two main approaches: melee (raycasting from tool) and ranged (raycasting from camera).
-- Server Script (ServerScriptService > GunHandler) local ShootEvent = game.ReplicatedStorage.ShootEvent local Players = game:GetService("Players") ShootEvent.OnServerEvent:Connect(function(player, origin, direction) local character = player.Character if not character then return end -- Validate max range (200 studs for a gun) if direction.Magnitude > 200 then direction = direction.Unit * 200 end -- Filter: ignore the shooter's character local params = RaycastParams.new() params.FilterType = Enum.RaycastFilterType.Blacklist params.FilterDescendantsInstances = { character } local result = workspace:Raycast(origin, direction, params) if result then local hitPart = result.Instance local hitChar = hitPart:FindFirstAncestorOfClass("Model") if hitChar then local humanoid = hitChar:FindFirstChild("Humanoid") if humanoid then // Check if hitChar belongs to a player for _, otherPlayer in Players:GetPlayers() do if otherPlayer.Character == hitChar and otherPlayer ~= player then local weapon = character:FindFirstChildOfClass("Tool") local damage = 15 if weapon and weapon:FindFirstChild("Damage") then damage = weapon.Damage.Value end -- Headshot multiplier if hitPart.Name == "Head" then damage *= 2 end humanoid:TakeDamage(damage) ShootEvent:FireAllClients(player, hitChar, result.Position, damage) break end end end end end end) -- LocalScript (StarterPack > Gun > GunClient) local tool = script.Parent local ShootEvent = game.ReplicatedStorage.ShootEvent local camera = workspace.CurrentCamera local cooldown = false tool.Activated:Connect(function() if cooldown then return end cooldown = true local origin = camera.CFrame.Position local direction = camera.CFrame.LookVector * 200 -- Client-side prediction: play muzzle flash local handle = tool:FindFirstChild("Handle") if handle then -- Spawn a bullet trail (local only) local trail = Instance.new("Part") trail.Size = Vector3.new(0.2, 0.2, 0.2) trail.BrickColor = BrickColor.new("Bright yellow") trail.CFrame = CFrame.new(handle.Position, origin + direction) trail.Anchored = true trail.CanCollide = false trail.Parent = workspace task.delay(0.1, function() trail:Destroy() end) end // Fire server for authoritative hit detection ShootEvent:FireServer(origin, direction) task.wait(0.3) -- Fire rate cooldown = false end)
Poorly optimized networking destroys player experience. Laggy remotes, excessive traffic, and unresponsive controls are the fastest way to lose players. Roblox has rate limits on remote events (~200–300 calls/second per client before throttling).
-- Server-side rate limiter (ServerScriptService > RateLimiter) local rateLimits = {} local RATE_LIMIT_WINDOW = 1 -- seconds local MAX_CALLS_PER_WINDOW = 5 local function CheckRateLimit(player) local now = os.clock() local playerLimits = rateLimits[player] if not playerLimits then rateLimits[player] = { count = 1, windowStart = now } return true end if now - playerLimits.windowStart > RATE_LIMIT_WINDOW then -- Reset window playerLimits.count = 1 playerLimits.windowStart = now return true end if playerLimits.count >= MAX_CALLS_PER_WINDOW then warn(`{player.Name} rate limited`) return false end playerLimits.count += 1 return true end -- Batch update example: damage all enemies at once local pendingDamageBatch = {} local function QueueDamage(target, amount) local key = target pendingDamageBatch[key] = (pendingDamageBatch[key] or 0) + amount end // Flush batch every 100ms local BatchEvent = game.ReplicatedStorage.BatchEvent while true do task.wait(0.1) for target, totalDamage in pendingDamageBatch do if target and target.Parent then local humanoid = target:FindFirstChild("Humanoid") if humanoid then humanoid:TakeDamage(totalDamage) end end end table.clear(pendingDamageBatch) -- Send batch event instead of many individual events BatchEvent:FireAllClients(damageBatch) end -- Attribute-based health update (no remote needed) -- Server sets attribute, client reads it for UI player.Character.Humanoid:SetAttribute("DisplayHealth", currentHealth) -- Client LocalScript reads attribute changes for health bar
The Actor container enables running Luau scripts in parallel threads. This is Roblox's solution for CPU-bound work that would otherwise block the main game thread. However, it is limited and often misunderstood.
-- Actor container setup (ServerScriptService > PathfindingActor) -- Create an Actor in ServerStorage, put a Script inside it -- Script inside the Actor (Actor > PathfinderScript) local PathfindingService = game:GetService("PathfindingService") local bindable = script.Parent:FindFirstChildOfClass("BindableEvent") local function ComputePath(startPos, endPos) local path = PathfindingService:CreatePath({ AgentRadius = 2, AgentHeight = 5, AgentCanJump = true, }) local success, waypoints = pcall(function() path:ComputeAsync(startPos, endPos) return path:GetWaypoints() end) return success and waypoints or nil end -- Listen for messages from the main thread while true do local _, startPos, endPos = bindable.Event:Wait() local result = ComputePath(startPos, endPos) // Send result back bindable:Fire(result) end -- Main thread Script (ServerScriptService > EnemyAI) local actor = game.ServerStorage.PathfindingActor:Clone() actor.Parent = workspace -- Create BindableEvent for communication local channel = Instance.new("BindableEvent") channel.Parent = actor // Fire work to the actor (non-blocking) channel:Fire(startPos, endPos) -- Listen for result channel.Event:Connect(function(result) if result then print("Path computed with", #result, "waypoints") end end)
Roblox supports two default rigs: R6 (6-body-part blocky) and R15 (15-body-part articulated). For custom NPCs or non-humanoid characters, you build your own rig with Motor6D joints and AnimationController.
-- Server Script: Create a custom NPC with a simple rig local function CreateCustomNPC(position) local model = Instance.new("Model") model.Name = "CustomNPC" model.Parent = workspace -- Create body parts local root = Instance.new("Part") root.Name = "HumanoidRootPart" root.Size = Vector3.new(3, 1, 1) root.Position = position root.Anchored = false root.CanCollide = true root.Parent = model local torso = Instance.new("Part") torso.Name = "Torso" torso.Size = Vector3.new(2, 2, 1) torso.BrickColor = BrickColor.new("Bright red") torso.Parent = model local head = Instance.new("Part") head.Name = "Head" head.Size = Vector3.new(1.5, 1.5, 1.5) head.Shape = Enum.PartType.Ball head.BrickColor = BrickColor.new("Bright yellow") head.Parent = model -- Connect with Motor6D joints local rootJoint = Instance.new("Motor6D") rootJoint.Name = "RootJoint" rootJoint.Part0 = root rootJoint.Part1 = torso rootJoint.C0 = CFrame.new(0, 0, 0) rootJoint.C1 = CFrame.new(0, 1, 0) rootJoint.Parent = root local neckJoint = Instance.new("Motor6D") neckJoint.Name = "Neck" neckJoint.Part0 = torso neckJoint.Part1 = head neckJoint.C0 = CFrame.new(0, 1, 0) neckJoint.C1 = CFrame.new(0, -0.75, 0) neckJoint.Parent = torso -- Add Humanoid (for health, movement, etc.) local humanoid = Instance.new("Humanoid") humanoid.HipHeight = 0 humanoid.MaxHealth = 100 humanoid.Health = 100 humanoid.Parent = model return model end -- Alternative: AnimationController for non-humanoid rigs -- For creatures / mechs that don't use Humanoid local function CreateMechRig(position) local model = Instance.new("Model") model.Name = "Mech" model.Parent = workspace local body = Instance.new("Part") body.Name = "Body" body.Size = Vector3.new(4, 3, 2) body.Position = position body.Anchored = false body.Parent = model local animController = Instance.new("AnimationController") animController.Parent = model local animator = Instance.new("Animator") animator.Parent = animController local animation = Instance.new("Animation") animation.AnimationId = "rbxassetid://1234567890" -- Replace with real ID animation.Parent = model // Play animation on the non-humanoid rig local track = animator:LoadAnimation(animation) track:Play() return model end
These mistakes are the most common causes of exploitable games, performance issues, and hard-to-debug bugs. Every solo developer makes them at some point. Learn to recognize them.
The cardinal sin. Never use client-provided values for damage amounts, item IDs, currency amounts, or permission checks without server-side validation. Always re-derive or validate on the server.
// WRONG: Client provides target position, server teleports TeleportEvent.OnServerEvent:Connect(function(player, targetPos) player.Character.HumanoidRootPart.Position = targetPos -- Exploiter teleports anywhere end) // RIGHT: Server validates teleport distance from last known position TeleportEvent.OnServerEvent:Connect(function(player, targetPos) local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart") if not root then return end local dist = (root.Position - targetPos).Magnitude if dist > 50 then return end -- Too far, reject root.Position = targetPos end)
Every InvokeServer blocks the client thread. If the server takes time (e.g., DataStore read), the client freezes. Use RemoteEvent + callback pattern instead.
Broadcasting updates for every minor change wastes bandwidth. If only one player should see a change (e.g., their personal UI), use FireClient. Batch updates for group changes.
Players on high ping (200–500ms) will experience delayed responses. Never assume a RemoteEvent listener fires instantly. Use timestamps server-side, not client-provided timestamps.
// WRONG: Using client time for cooldowns CooldownEvent.OnServerEvent:Connect(function(player, clientTime) if clientTime - lastUsed[player] > 1 then -- Client can lie about time -- process end end) // RIGHT: Server tracks time independently CooldownEvent.OnServerEvent:Connect(function(player) local now = os.clock() if now - lastUsed[player] > 1 then lastUsed[player] = now -- process end end)
Every :Connect() creates a reference. If you connect to CharacterAdded without disconnecting old connections when a character respawns, you get connection leaks. This causes memory bloat and duplicate event fires. Use RBXScriptSignal:Once() for one-shot listeners, or store and disconnect connections.
// WRONG: New connection every respawn, old ones persist Players.PlayerAdded:Connect(function(player) player.CharacterAdded:Connect(function(character) print("Character loaded") -- Runs multiple times per respawn! end) end) // RIGHT: Disconnect old connection before creating new one Players.PlayerAdded:Connect(function(player) local connection = nil -- scope for cleanup connection = player.CharacterAdded:Connect(function(character) print("Character loaded (clean connection)") // If needed, disconnect and reconnect end) // Disconnect when player leaves player.AncestryChanged:Connect(function(_, parent) if not parent and connection then connection:Disconnect() end end) end) // Alternative: use a table of connections per player local playerConnections = {} Players.PlayerAdded:Connect(function(player) local connections = {} table.insert(connections, player.CharacterAdded:Connect(function(character) print(`{player.Name} respawned`) end)) table.insert(connections, player.CharacterRemoving:Connect(function(character) print(`{player.Name} removed`) end)) playerConnections[player] = connections end) Players.PlayerRemoving:Connect(function(player) // Clean up all connections for this player local connections = playerConnections[player] if connections then for _, conn in connections do conn:Disconnect() end playerConnections[player] = nil end end)