Listen… we gotta talk. You’ve got some really bad habits when it comes to scripting on Roblox. You clearly grew a lot since your days as a novice. You’ve been around the block enough times to know when it’s time to move past old habits and grow as a developer. Now’s the time to start improving your Roblox code.
OK, maybe not all of that first paragraph is completely true, but that bit about growing as a developer might interest you a whole ton. In this post, let’s talk about what you can do to clean up your coding habits.
A word of caution: this “advice” (if we’re calling it that) isn’t for the absolute novice. If you’re really-really new, just focus on making things work first and foremost. That’s more important. For the rest of you, let’s get into it!
0. Indent your code correctly
I’m gonna number this as zero because this really should be handled already by your code editor. Unfortunately, even with the smartest auto-indent feature, you’ll still screw up your code indentation from time to time. Luau may not care about whitespace, but you absolutely must indent properly for the humans that read your code. It is downright infuriating when someone asks for help with code that has this problem!
Tip: In most editors, including Roblox Studio, selecting multiple lines and hitting Tab will indent all the lines at once. You can un-indent by pressing Shift+Tab. In Studio, Alt+Shift+F will automatically format your selection!
The general rule of thumb is to indent once per token that needs to be closed with another token. So this means if
, while
, for
, (
, {
, etc. Then, un-indent on the same line as the closing token, end
,)
or }
. In addition, if you have a statement that spans multiple lines, you generally want to indent that as well. See the conditional for this return
statement:
local function canDoTheThing(score, coins)
return score >= 5 and score <= 10
or coins >= 2 and coins <= 7
end
If all else fails, just trust your text editor or IDE. It will typically do the heavy lifting for you on this: just type normally. Oh, and it really doesn’t matter if you use tabs or spaces. Some languages and certain people have strong preferences, though. On Roblox, we generally use tabs, but this is configurable. Just be consistent!
1. Endless script.Parent.Parent.Parent.Parent…
Often times a one-off script is buried deep in your game’s hierarchy. That’s not always a bad thing, but what can be problematic is having long chains of the Parent property. This can cause problems if you’re doing something like this:
local function onClick()
script.Parent.Parent.Parent.Parent.TV.SurfaceGui.TextLabel.Text = "Open"
end
script.Parent.MouseClick:Connect(onClick)
This is a code smell indicating potential dependency problems. It’s also generally hard to read because your eyes can’t count the number of .Parent
s very quickly. More importantly, you have to actually fire MouseClick
to figure out if that long chain is correct. It is better to assign variables to each of the objects at the beginning:
local clickDetector = script.Parent
local part = clickDetector.Parent
local model = part.Parent
local folder = model.Parent
local tv = folder.TV
local surfaceGui = tv.SurfaceGui
local textLabel = surfaceGui.TextLabel
local function onClick()
textLabel.Text = "Open"
end
clickDetector.MouseClick:Connect(onClick)
“But wait,” you exclaim. “How does that look any better? It’s just a wall of local variables!” Sure, you might admit to yourself that there’s a small benefit in case an object is missing. An error would be produced when the script runs, not after MouseClick
fires… but you can’t shake the feeling this is long-winded and somehow worse.
You’re right. It does look messy! That’s not the code’s fault, though: it’s because the game hierarchy is messy, which is the true problem. That’s why it’s called a code smell – the scent isn’t necessarily a problem, it’s the thing that is causing it which is. In this contrived example, you’d need to move the script outside the ClickDetector and put it somewhere more sensible.
2. Devil Loops: while wait() do ... end
/ while true do wait() ... end
You want a loop that always runs, does so really fast, and blocks the next cycle until the current one is complete. So many constraints! Thankfully we have this delightful syntax sugar that works for everything…right? WRONG. It’s really problematic and I could write a whole article on it. However, I’ll save everyone the trouble and use bullet points:
wait
throttles! Each step, Roblox resumes threads that are waiting. If those threads to take too long, it’ll put off resuming those threads until the next step. Since you’re trying to do this every step, you could inadvertently affect all other waiting threads/scripts. Similarly, this affectsspawn
anddelay
too.- The loop doesn’t run immediately the first time –
wait
has to be called first and its result returned before the loop runs. For most cases, this is entirely unnecessary! - It’s just some syntax sugar that really isn’t that sweet. You know that
wait
returns a number, right? Yeah, sure, numbers are truthy and that’s why this works… but at the end of the day, this isn’t a boolean or conditional expression at all. You’re degrading readability here. You might as well just writewhile 1 do
,while "" do
orrepeat ... until wait()
. This is just playing code golf to insignificantly reduce your line count by one. - Not all loops are created equal. What are you even doing in this loop? Animating? Checking an input? Moving the camera? Inspecting the physics state? Detecting a change in some value? These are all important… and inherently different! There is no one-loop-fits-all solution.
So Many Great Alternatives
That last bit is the most important: almost every use case has a much better alternative, and most of the time involves using a RunService event or function. Here’s a handy-dandy quick guide.
Inspecting or modifying physics state Measuring elapsed time | RunService.Stepped (Does not fire when the game is paused.) |
Animations Checking for input Moving the camera | RunService:BindToRenderStep (preferred) RunService.RenderStepped |
Detecting a value change | Changed GetPropertyChangedSignal |
Writing a plugin | RunService.Heartbeat (Fires even when the game is paused) |
Benchmarking | os.clock debug.profilebegin/profileend |
What About Blocking?
You might like while-true-do loops because one cycle has to complete before the next one begins. You can still do that here – just use a debounce like you’ve probably done countless times with Touched.
local debounce = false
game:GetService("RunService").Stepped:Connect(function (t, dt)
if not debounce and theTimeIsRight() then
debounce = true
doSomething()
debounce = false
end
end)
3. Abusing Anonymous Functions (with PlayerAdded)
Often seen connected to events, these take the form (function () ... end)
usually. They are nameless functions, usually connected one-off to some event like Touched. In most cases, it’s acceptable. But there are certain situations you want to avoid anonymous functions because you’ll need to use the function yourself at some point or the meaning/role of the function necessitates it.
Typical Anonymous Function Usage
game:GetService("Players").PlayerAdded:Connect(function (player)
-- Check if they're banned...
-- ...initialize game state...
-- ...load data store entry...
-- ...you get the picture.
end)
The PlayerAdded
case is a classic example. What happens if a player is already present before this Connect
runs? They’d get missed. The function we’ve written carries the implication that it will run for every player. We should give the function a proper name (I prefer onPlayer
or onPlayerAdded
in this case) and call it for each already-present player.
A Named Player-Processing Function
local Players = game:GetService("Players")
local function onPlayer(player)
-- ...
end
Players.PlayerAdded:Connect(onPlayer)
for _, player in pairs(Players:GetPlayers()) do
task.spawn(onPlayer, player)
end
Notice the usage of the new task.spawn function: using it like this simulates the PlayerAdded event firing, which allows your function to yield while still processing each player serially (one at a time). Plus, it’s not like spawn ’cause it can take additional arguments to pass to the spawned function. Neat!
4. You forgot to Unit Test
A unit test is some code that ensures one part of your code (typically a function) works as expected. Code coverage refers to how much of your code has unit test (ideally, this is close to 100%). Unfortunately… unit testing isn’t something I see developers doing incorrectly, but rather just not at all. Instead of recommending you learn the latest-and-greatest testing library, let’s just try things the old fashioned way. Start with something like this:
Example Test Suite Boilerplate
local MyGameTests = {}
function MyGameTests.run()
-- Use task.spawn to run a bunch of tests at once, be careful though!
task.spawn(MyGameTests.test_foo)
end
function MyGameTests.test_foo()
local Foo = require(...) -- Consider moving to top of script
local result = Foo.bar()
assert(result == "expected value", "Foo.bar broke")
end
return MyGameTests
…and a Script which Invokes It
local RunService = game:GetService("RunService")
local MyGameTests = require(...)
if not RunService:IsStudio() then
MyGameTests.run()
end
Now go and write a bunch of code that uses your game functions! Inspect the return value as well as the state of the game after each function, and use assert
and error
to raise complaints if something isn’t right. Remember all those anonymous functions you wrote? Go give them names and call them directly, as if their respective events occurred. ModuleScripts are your friend here.
Just go nuts with the automation! You shouldn’t have to walk over to every ClickDetector in Studio to feel good about everything working. Building your own test suite at first can be a valuable learning experience before you dive into the world of testing libraries.
OK, venting done…
I admit I wrote most of this out of frustration 😅 I’ll probably write a sequel to this. Hopefully I channeled the frustration I have with these common issues into a small guide that can be a smidge useful.
Are you guilty of any of these? Do you get just as frustrated as I do when you see these? Does none of this really matter in the long run!? Let me know.