Roblox Lua Scripting Basics You Actually Need
Most people starting with Roblox scripting jump straight into copying code from YouTube tutorials without understanding what the pieces do. That works fine until you hit an error that isn't in any tutorial, and then you're stuck. I've spent years debugging other people's scripts and the pattern is always the same: they don't understand the execution order or how the client-server boundary works. Here's how it actually goes. You write a LocalScript when you need something on the player's machine, and a regular Script when you need server authority. Place them in the right folders and they run automatically, but only if you put them where Roblox knows to look. StarterPlayerScripts runs on each player connect, ServerScriptService runs once when the game starts, and Workspace is for things that affect the world directly.
Getting Started With Your First Parkour Script
To unlock the power of Parkour Legacy your ultimate guide to Roblox scripting, you don't need anything fancy. Start by creating a part in the workspace, add a Script inside it, and put this basic blocker script in there to understand how events work: local part = script.Parent
local debounce = false
part.Touched:Connect(function(hit)
if hit.Parent:FindFirstChild("Humanoid") and not debounce then
debounce = true
local humanoid = hit.Parent.Humanoid
humanoid.Health = 0
wait(2)
debounce = false
end
end) This is a kill brick, the most basic obstacle in any parkour game. When a player touches it, the Humanoid's health drops to zero and they respawn. The debounce variable prevents the same trigger from firing twice in rapid succession, which matters because Touched fires multiple times per second as long as the player is standing on the part.
Understanding the Client-Server Boundary
This is where most beginners break their games. You can't change a player's position from a server Script and expect it to stick unless you use RemoteEvents properly, and you can't modify server-side data from a LocalScript at all. The engine enforces this boundary because it prevents cheating, but it also means you need to plan your architecture before writing anything. A common setup for a parkour checkpoint system looks like this: the server owns all the checkpoint data, and when a player touches a checkpoint part, a LocalScript fires a RemoteEvent that tells the server to update that player's progress. The server then stores the new position in a table keyed by the player's userId. If you skip the RemoteEvent and try to modify the checkpoint data directly from the client, it either does nothing or creates a desync where some players see different positions than others. I ran into this exact problem last month while working on a timing-based parkour map. A developer wanted to sync player animation playback across all clients based on individual completion times. They were firing events every frame to update a shared timer display, which caused massive network traffic and eventually a throttling ban on the server. The fix was to only fire on significant time changes, and even better, to have the server calculate the authoritative time and only broadcast updates when it actually changed, which reduced network activity by about ninety percent.
Get the Full Details

Building a Proper Checkpoint System
Here's a more complete example that handles the client-server split correctly. The Script goes in ServerScriptService and manages the data: local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local checkpoints = {}
local playerData = {}
local checkEvent = Instance.new("RemoteEvent")
checkEvent.Name = "CheckpointTriggered"
checkEvent.Parent = ReplicatedStorage
for _, part in pairs(workspace.Checkpoints:GetChildren()) do
checkpoints[part.Name] = part.Position
end
Players.PlayerAdded:Connect(function(player)
playerData[player.UserId] = { lastCheckpoint = workspace.SpawnLocation.Position }
checkEvent.OnServerEvent:Connect(function(player, checkpointName)
if checkpoints[checkpointName] then
playerData[player.UserId].lastCheckpoint = checkpoints[checkpointName]
playerData[player.UserId].checkpointsCompleted = (playerData[player.UserId].checkpointsCompleted or 0) + 1
end
end
end) The LocalScript goes inside each checkpoint part:
local checkEvent = game:GetService("ReplicatedStorage"):WaitForChild("CheckpointTriggered")
script.Parent.Touched:Connect(function(hit)
if hit.Parent:FindFirstChild("Humanoid") then
checkEvent:FireServer(script.Parent.Name)
end
end)
Performance Pitfalls to Avoid
Script performance in Roblox is usually not a problem unless you write code that fights the engine. The two most common mistakes are using wait() for loops that should use RunService and doing unnecessary instance lookups inside event handlers. Never use a loop with wait() for something that needs to run every frame. Use RunService.Heartbeat or RunService.RenderStepped instead. A loop that yields for 0.03 seconds between iterations will introduce visible stutter in a parkour game where frame timing matters for obstacle collision. Heartbeat runs after physics update and gives you about a 16 millisecond window, which is plenty for updating visual elements or checking player positions. Another thing that silently kills performance: calling FindFirstChild or GetPlayerFromCharacter inside a Touched event without caching the reference first. These operations are cheap individually but if you have hundreds of trigger parts in a busy map and every one fires on player movement, the garbage collector starts working overtime. Cache your player references in a dictionary keyed by character instance, and remove them on PlayerRemoving. This keeps your memory stable and your frame rate predictable.

Debugging When Things Go Wrong
When a script fails in Roblox, the output window gives you a stack trace but it doesn't always tell you the real problem. I learned this the hard way when a parkour game's timer was showing negative values. The error message said the issue was in a subtraction operation, but the actual bug was that the server was resetting the timer every time a player died, and the local display wasn't accounting for the reset fast enough. The fix was to add a timestamp check so the client would ignore any server update older than the one it had already displayed. Use print statements sparingly. Logging every single frame or every touch event will clutter the output and make it hard to find the actual error. Instead, set up a debug mode flag that you can toggle on and off, and only log when that flag is true. You can also use workspace:GetDescendants() to list all parts and verify that your trigger volumes are the size and position you expect. Sometimes the bug isn't in the code at all, it's in the map layout. If you're building a complete parkour experience, plan your script structure around the game loop: initialization when players join, active gameplay events during the run, and cleanup when the round ends. This modular approach makes it easier to isolate problems. If the checkpoint system breaks, you know exactly where to look. If the timer is wrong, you check the time module instead of hunting through twenty different scripts.