If you’ve been scripting in Roblox for a while, you’ve probably used wait() and while true do loops to create repeating behavior. But as your games become more complex, you’ll quickly discover that these approaches have limitations—they’re imprecise, can cause performance issues, and don’t sync well with Roblox’s rendering engine.
That’s where RunService comes in. RunService is one of the most powerful tools in your scripting toolkit, allowing you to create custom game loops that run in perfect harmony with Roblox’s frame rendering. In this tutorial, we’ll explore what RunService is, why it’s superior to traditional loops, and how to use it effectively in your games.
What Is RunService?
RunService is a built-in Roblox service that provides events that fire at specific points during each frame of your game. Think of it as a way to “hook into” Roblox’s internal game loop, allowing your code to run at just the right moments.
The most important thing to understand is why this matters. Every frame, Roblox goes through a cycle: it processes physics, handles inputs, updates character positions, renders graphics, and more. If your code runs at random times using wait(), it might conflict with these internal processes or run at inconsistent intervals. RunService events let you synchronize with this cycle perfectly.
The Main RunService Events
RunService provides several events, but these three are the ones you’ll use most often:
- Heartbeat: Fires every frame, right before the physics simulation
- Stepped: Fires every frame, right after the physics simulation
- RenderStepped: Fires every frame, right before rendering (client-only)
Let’s break down when and why you’d use each one.
Heartbeat: Your Go-To Event
For most gameplay logic, Heartbeat is your best choice. It fires at a consistent rate (usually around 60 times per second) and works on both the client and server. Use Heartbeat for:
- Updating game timers
- Checking player states
- Managing custom AI behavior
- Updating UI elements that don’t need frame-perfect timing
Here’s a basic example that updates a timer:
local RunService = game:GetService("RunService")
local elapsedTime = 0
local connection = RunService.Heartbeat:Connect(function(deltaTime)
elapsedTime = elapsedTime + deltaTime
print("Elapsed time:", math.floor(elapsedTime), "seconds")
end)
Notice the deltaTime parameter. This is crucial—it tells you exactly how much time passed since the last frame. This makes your code frame-rate independent, meaning it will work the same whether someone is running at 30 FPS or 144 FPS.
RenderStepped: For Visual Smoothness
RenderStepped fires just before each frame is rendered, making it perfect for camera effects and smooth visual updates. However, it only works on the client (LocalScripts), never on the server.
Use RenderStepped for:
- Custom camera systems
- Smooth interpolation of visual elements
- First-person viewmodels
- Visual effects that need to be perfectly synced with rendering
Here’s an example of a simple camera shake effect:
local RunService = game:GetService("RunService")
local camera = workspace.CurrentCamera
local shakeIntensity = 0.5
RunService.RenderStepped:Connect(function()
local shake = Vector3.new(
math.random() * shakeIntensity - shakeIntensity/2,
math.random() * shakeIntensity - shakeIntensity/2,
0
)
camera.CFrame = camera.CFrame * CFrame.new(shake)
end)
Stepped: The Less Common Choice
Stepped fires after physics calculations. It’s less commonly used, but can be helpful when you need to respond to physics changes immediately. Most of the time, Heartbeat is a better choice unless you specifically need post-physics timing.
Why RunService Beats Traditional Loops
Let’s compare the old way with the RunService way to see why it’s better:
-- The old way (❌ Not recommended)
while true do
wait(1)
updateTimer()
end
Problems with this approach:
wait(1)doesn’t wait exactly 1 second—it’s imprecise- The loop doesn’t sync with frame rendering
- If
updateTimer()takes time to run, the interval becomes even less accurate - If the script errors, your entire loop breaks
-- The RunService way (✅ Recommended)
local RunService = game:GetService("RunService")
local elapsed = 0
RunService.Heartbeat:Connect(function(deltaTime)
elapsed = elapsed + deltaTime
if elapsed >= 1 then
elapsed = elapsed - 1
updateTimer()
end
end)
This version is frame-rate independent, precise, and won’t break your entire system if updateTimer() errors—only that one frame’s update will fail.
Practical Example: Creating a Stamina System
Let’s build something real: a stamina system that depletes while sprinting and regenerates when idle. This demonstrates how to use RunService effectively for gameplay mechanics.
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local player = Players.LocalPlayer
-- Stamina configuration
local maxStamina = 100
local currentStamina = maxStamina
local depletionRate = 20 -- per second
local regenRate = 10 -- per second
local regenDelay = 2 -- seconds before regen starts
local timeSinceLastUse = 0
local isSprinting = false
-- Listen for sprint input
local UserInputService = game:GetService("UserInputService")
UserInputService.InputBegan:Connect(function(input)
if input.KeyCode == Enum.KeyCode.LeftShift then
isSprinting = true
end
end)
UserInputService.InputEnded:Connect(function(input)
if input.KeyCode == Enum.KeyCode.LeftShift then
isSprinting = false
end
end)
-- Update stamina every frame
RunService.Heartbeat:Connect(function(deltaTime)
if isSprinting and currentStamina > 0 then
-- Deplete stamina
currentStamina = math.max(0, currentStamina - (depletionRate * deltaTime))
timeSinceLastUse = 0
-- Update player speed
local humanoid = player.Character and player.Character:FindFirstChild("Humanoid")
if humanoid then
humanoid.WalkSpeed = 24
end
else
-- Regenerate stamina after delay
timeSinceLastUse = timeSinceLastUse + deltaTime
if timeSinceLastUse >= regenDelay then
currentStamina = math.min(maxStamina, currentStamina + (regenRate * deltaTime))
end
-- Reset to normal speed
local humanoid = player.Character and player.Character:FindFirstChild("Humanoid")
if humanoid then
humanoid.WalkSpeed = 16
end
end
-- Force stop sprinting if out of stamina
if currentStamina <= 0 then
isSprinting = false
end
print("Stamina:", math.floor(currentStamina))
end)
This system is smooth, responsive, and frame-rate independent thanks to deltaTime. Notice how we multiply our rates by deltaTime—this ensures that whether the game runs at 30 FPS or 240 FPS, stamina depletes at the same real-world speed.
Managing Connections: Preventing Memory Leaks
One critical aspect of using RunService is properly disconnecting your connections when they're no longer needed. Every :Connect() returns a connection object that you should store and disconnect later:
local connection = RunService.Heartbeat:Connect(function(deltaTime)
-- Your code here
end)
-- Later, when you're done:
connection:Disconnect()
This is especially important for systems tied to specific game states or objects. For example, if you create a RunService connection for a special power-up effect, disconnect it when the effect ends:
local function activateSpeedBoost(duration)
local elapsed = 0
local humanoid = player.Character:FindFirstChild("Humanoid")
local connection
connection = RunService.Heartbeat:Connect(function(deltaTime)
elapsed = elapsed + deltaTime
if elapsed >= duration then
humanoid.WalkSpeed = 16 -- Reset to normal
connection:Disconnect() -- Clean up!
else
humanoid.WalkSpeed = 32 -- Boosted speed
end
end)
end
Performance Considerations
While RunService is efficient, keep these performance tips in mind:
- Minimize work per frame: Remember your function runs every frame (potentially 60+ times per second). Avoid expensive operations like :FindFirstChild() in every frame—cache references instead.
- Use the right event: Don't use RenderStepped for logic that could run in Heartbeat. RenderStepped runs even more frequently on high-refresh-rate displays.
- Disconnect when done: Unused connections waste resources and can cause unexpected behavior.
- Consider throttling: Not everything needs to update every frame. Use a timer pattern to update some systems every few frames instead.
Recap and Next Steps
You've learned that RunService is the professional way to create game loops in Roblox. By using Heartbeat for gameplay logic and RenderStepped for visual effects, you can create smooth, efficient systems that work consistently across all devices and frame rates. Always remember to use deltaTime to make your code frame-rate independent, and disconnect connections when you're finished with them.
Now that you understand custom game loops, you're ready to explore more advanced topics like object pooling for performance optimization or creating state machines that work with RunService. These patterns will help you build even more sophisticated gameplay systems. Happy scripting!