Hello! This guide is massive, and is relevant whether you are a beginner, intermediate, or advanced programmer. No matter how much experience you have, I have compiled a LOT of things into this post. I’ve tried to keep it straightforward for everyone. Even if you have no idea what I’m talking about at times, I really encourage you to keep reading, because there’s a lot of great stuff in here.
Intro
In this post, I go over code structure, Git, Rojo, wally, various libraries and tools, etc. There is a lot more than this out there, and I am going to keep adding more relevant information to this post in the future.
My goal is to get a lot of great software engineering principles into the wild on Roblox, since it’s something that there are not a lot of great resources on yet.
In general, the cleaner and more straightforward your code is, the easier it will be to work on, and the more natural it will be to write things in ways that are fast and easy to keep track of. Cleanly structured code and performance aren’t directly correlated, but clean code is usually faster, and it is easier to optimize where it matters the most even if it is not the fastest it possibly could be. Too fast is a problem if it means the code is hard to edit and understand.
When you’ve got a game that will eventually have hundreds and hundreds of modules in it, the last thing you want is to accidentally encourage the sorts of things that will cause the code to get too tangled up. Those tangles can cement your code in a state where little changes could take a lot of work, and it makes it hard to follow and understand what’s going on.
There are a lot of great tools and libraries which address various things. They are each relevant in specific cases, and it is my goal in this post to give a basic, overall view of many of these things, how they are used, etc. All of these concepts are things that took me many years to fully grasp, and they are all things that are often misunderstood or not very well known about on Roblox, and I would love to accelerate the process for others by providing as much accurate and useful information as I can.
Git & Rojo
...
GitHub/GitLab and Rojo are great for having good code structure. Even if you do not fully utilize git the best that you could, it’s a great way to have a history of things, so that you can easily go back to any version of your code.
Together, these allow you to utilize wally, and they allow you to utilize VSCode. The Luau LSP extension will even give you the exact same Luau typechecking as in Roblox Studio. You get things like the F2 keybind for renaming variables. You get Ctrl+P for being able to type in the name of a module and just open it up right away. You get things like Wally, which allows you to access an enormous amount of useful libraries and packages, and even create your own. And you get lots of other fun stuff too.
Git
Every “commit” in git is nothing more than a little change to the code. It’s really common to have a lot of commits. I use GitHub desktop to make it easy to select which lines I want to go into a commit if I’ve made some changes in a few places. This makes it easy to separate different changes and then you can compose different versions of your code that have different features and all kinds of cool stuff. Removing a commit removes the changes, adding a commit adds them. All the commits stacked together make the latest version of your code.
Every “branch” in git is nothing more than a collection of changes. This means that you can work on multiple features at the same time, with each feature as a separate branch, and you can add or remove features at will. It feels like magic when you learn how to use it, because it lets you fluidly add and remove entire features, it lets you have access to every change that you have ever made, and it means that if something is taking a while to get working, it won’t stop you from continuing to work on and develop your game.
In GitHub desktop whenever you wanna merge in some changes you will be offered two particularly interesting options.
When you update your main branch with new code, you can do something called “rebasing” and rebase main into your other feature branches. Rebasing is a strategy for combining branches. What rebasing basically is is like taking your main branch, stacking your feature’s changes on top of it, and boom, that’s your new feature branch. This is the best way to keep your feature branches up to date, and it will avoid merge conflicts because ultimately when you eventually wanna merge your features into your main branch, when those features are done, and stable, for git it is as simple as slapping all your new changes on top.
Merging is like the opposite of rebasing. If you were to merge main into your feature instead, you would see that all new changes that you added to main actually go on top of your feature’s changes. But you don’t want that, because now all your features are all tangled together in the git history.
So the general rule of thumb is merge features into main, rebase main into features. It’s also really good to make sure that your feature is up to date with your main version of the code before you merge it into main, because that means you can really easily test with all the latest code. The more frequently you rebase main into your feature code, the less likely you will have merge conflicts, which is when two commits change the same lines of code.
When you have a merge conflict, you basically just have to figure out what combining the two changes should really look like, which ofc can mean a lot of different things. By rebasing main into your feature code, you make less merge conflicts by always being up to date, and you solve any merge conflicts ahead of time, that way you don’t accidentally make your main branch a bit messy or broken.
Rojo
As for Rojo, Rojo is what allows you to utilize git. Rojo unlocks a LOT of cool tools like Wally.
Libraries, components, objects, and more, the super-guide
...
Signals
Signals themselves are probably the most notable example there because they exist in every codebase. They are just events, and events are amazing because they are the one true cure to circular dependencies.
What if you want to give a player a reward when they unlock an area? Instead of inserting some code into the zones code to check for rewards to give and giving the rewards and all that stuff, provide a :GetZoneUnlockedSignal
method that creates a signal for when a particular zone is unlocked. Don’t worry about if your implementation is good or bad, because at the end of the day, you can make lots of optimizations and improvements to your :GetZoneUnlockedSignal
method without ever changing the way that it works for any code that needs it to work.
Now you don’t have to have any stuff even remotely related to the idea of rewards inside of your zones code, and now you can add a new module that’ll listen for these signals and dish out those rewards. You can even automate it all with a simple table of rewards keyed by zone.
Troves
Troves (an alternative to Maids which are an almost identical concept) make cleanup easy in object oriented code. Inside the code which creates an object, make yourself a trove, and you can connect to events or signals, create instances, etc. Inside the code for when your object is destroyed, simply call the trove’s :Clean()
method. If you’re using a trove you can guarantee that cleanup will happen when you want it to, which means no memory leaks.
Observers
Observers (aka “the observer pattern”) exist somewhere between a Trove
and a Signal
. The idea of an observer is that it will “observe” the current state of whatever you want it to immediately, for example, all the players in your game. It will also observe any future players that join your game. In multiple cases, for example with players, children, etc the observer can also handle when that child or player is removed.
Here’s a great example using version 0.1.4 of @4812571’s Observe
library, which I linked earlier:
-- Without observers
for _, player in Players:GetPlayers() do
doSomethingWithPlayer(player)
end
Players.PlayerAdded:Connect(doSomethingWithPlayer)
Players.PlayerRemoving:Connect(function(player)
cleanUpPlayerStuff(player)
end)
-- With observers
Observe.ObservePlayers(function(player)
-- Do something with the player
return function()
-- Clean up player stuff
end
end)
An observer will return a function you’d call to remove the observer. For example, if you observe a property, you can disconnect the observer by calling the function it returned. The callback of an observer can also return a function which will be called with that value is cleaned up. For example, when a player leaves the game, the function returned by the observer callback will be called so that you can handle cleaning up all the stuff for that player.
Observers can also pair really nicely with troves:
self._trove = Trove.new()
self._trove:Add(Observe.ObserveAttribute(instance, "ExplosionPower", function(explosionPower)
self.ExplosionPower = explosionPower
end))
Components
Components (aka “the binder pattern”) are an OOP concept that will make your OOP code OOP (over-over-powered). The idea is simple. What if everything had multiple “objects”? Instead of something being a single object, you can attach components to that object by giving it multiple CollectionService
tags for example, and those tags will automatically construct and assign components to objects.
Components are a step up from regular OOP, because you can stop thinking about objects as objects and start thinking about objects as objects with behaviours. You can make generic components that add special behaviours to any object, like proximity prompts, lights, etc. You can also use components to describe features in your games like doors, which can open and close, and might tween.
You could create an Explosive
component, which has an :Explode()
method. Then you could create a Health
component for things which have health, like the player, or a prop in the world. You could have an explosive barrel with a Health
component, an Explosive
component, and an ExplosiveBarrel
component to describe the unique behaviour of the explosive barrel, which is that it explodes when destroyed. Now when the explosive barrel has “died” (which you can use a Signal on the Health
component for), you can call the Explosive
component’s :Explode()
method. The explosive component can also look at attributes to figure out how big the explosion should be, how much damage, etc.
There are a few main pieces to a component:
- Creating the component for an instance
- Adding behaviours for the component and the instances it is a part of
- Cleaning up the behaviours that the component adds
- Destroying the component with the instance
There are also a few things you wanna be able to do when there are multiple components involved:
- You want to be able to look for specific components for instances, if they exist
- You might want to be able to wait for components to potentially exist (Promises would be very relevant here. You can even add the promise to a trove with Trove’s
:AddPromise
so that it will be cancelled and you’re not waiting forever, with is a really clean way to indicate that you’re done waiting for stuff)
With signals and methods on your components you can have your components work just like any other modules, with optional getting or waiting for components acting exactly like module requires.
Components can be added or removed to an object, and they should be completely cleaned up when the object is deleted, or when the component is removed from the object. Utilizing observers, troves, promises, etc makes it really easy to write components without any memory leaks, that are easy to read and understand, and have really complicated behaviours described in really simple and straightforward ways. Components are exactly like regular modules, and exactly like regular objects in OOP.
It’s really common to see attributes used by components as extra fields. In fact, using components & attributes is pretty similar to making games on Unity, because the architecture is equivalent.
Sleitnick has a Component
library for basic use. It has some coupling issues like Knit, but I use it a lot myself.
Promises
Promises are a concept that’s really useful in game programming. They let you convert between things, schedule things, they are cancellable, and more. Promises will help to avoid race conditions which are way more common than you might think, because race conditions can have a very low chance of occurring or may only occur in specific scenarios making them seem as if they never come up.
Promises are great for networking, for example using promises in client code to make requests to the server which require a result. Promise are also really great for anything which takes time. And they’re really great for stuff that can fail, such as loading player data.
For example, maybe the player leaves the game while you’re getting their data still. Well, with promises you can just discard the promise and never resolve it, and now your promise’s callbacks will never run, so it’s safe. This also doesn’t result in any memory leaks, even when using :await()
or :expect()
. You could also reject, to let the promise’s user (typically yourself) know that the data has failed to load.
These are some really cool mechanics of promises that are not obvious at first:
- If a promise results in a promise, the result isn’t a brand new promise, instead, once it resolves or rejects or cancels you get a result. This means that you can return promises from promises to schedule things in a really clean, predictable, and easy to read and edit way.
- The callback of
:andThen
for example is not just a callback.:andThen
actually makes a new promise, which will resolve to whatever the callback resolves to. This means you can convert things into eachother, and perform intermediary steps for things in code. -
:catch
actually works the same way too. The catch method allows you to handle errors, then they will produce a new promise that actually resolves with whatever catch’s callback returned. You can use this to turn an error into something happy, it doesn’t just have to be for error handling.
Side note, but, I recently made a simple tween
library on wally as a “promisified” replacement for TweenService. I am working on allowing you to compose tweens for multiple instances in a nice way too. It’s nothing complicated at all, purely for convenience.
Later I would additionally like to offer a Tween.value
with an :Observe
method, which will follow the observer pattern to let you tween values without creating intermediary instances.
It’s a good, simple demonstration of how promises can be useful. You can sequence stuff pretty easily by doing stuff like this:
local tweenA = Tween.new(instanceA, tweenInfoA, propertiesA)
local tweenB = Tween.new(instanceB, tweenInfoB, propertiesB)
-- Play tween A, and then play tween B 1 second after it is finished
local tweenABeforeB = tweenA:Play()
:andThenCall(Promise.delay, 1)
:andThen(function()
tweenB:Play()
end)
Tween.new
accepts the same arguments as TweenService:Create
.
Tween
provides :Play()
, :Cancel()
, and :Pause()
like Roblox tweens, the only difference being that :Play()
returns a Promise which resolves when the tween completes. You can also call :cancel()
on the promise, and it will even cancel the tween.
There is also Tween.all
which takes in an array of tween objects and returns a new TweenGroup
object, which does all the above things with the same APIs as above, but will act on all the tweens it contains, which is really useful for when you have something with a lot of moving or changing pieces. For example, maybe you have a door with two pieces. You can actually construct a close and an open TweenGroup
using this, and you can even re-use the tween objects just like with TweenService
.
At the moment, I will note that currently tween groups are a work in progress and aren’t fully ready, especially because TweenGroup:Pause()
does not actually work as intended yet. It pauses the tweens but you are unable to resume it which is something I fixed the other day (and haven’t published), but I wanted to test everything thoroughly first so that TweenGroup
works as expected.
Structure & architecture
...
The way I personally structure my code is really simple, and straightforward, and for good reason. I don’t use any frameworks, and I don’t have any complicated wrappers, just modules to modules. Fundamentally, my code is still structured identically to how I would structure it if I were using Knit.
There may be multiple modules related to a single feature, and for this reason, they can be grouped up into folders.
Circularity
Circular dependencies can come up a lot, and it isn’t often clear at first why they are to be avoided. The reason is really as simple as code structure. Two things can rely heavily on eachother, but you always want a clear directionality so that what you need to change to make stuff happen is clearer, and so that your code will only affect the things that depend on it. When you have circular requires, you end up with a muddied structure where it is unclear which thing depends on what, and so not only can it be hard to figure out if the code needs to change in one place or another to achieve something, it can result in bugs that are harder to understand and diagnose too.
As a simple example, let’s say you want to purchase an area. But to purchase an area, you wanna take the correct kind of currency. But you only want that currency to be unlocked if the player has a zone that provides them that currency. Well, now you have a problem, because your zones code needs your currency code, and your currency code needs your zones code.
The solution is simple in hindsight. If the purchase logic is its own thing, the circularity is resolved simply because the zones purchasing can rely on the zones code and the currency code. Now the currencies code can happily rely on the zones code.
When you split up features is arbitrary, and entirely your choice. Too many splits and it’s excessive and the structure becomes muddy, too few and the structure becomes muddy. So it is a balance between both, but with an emphasis on the idea that splitting is at the very least not going to ruin a codebase.
The most natural point where you will split something is one where the behaviour is related. The behaviour of zones as a concept is its own unique thing, and the behaviour of purchasing zones is its own unique thing. This is a really natural way to think about your code too, thinking about it by its concepts and its behaviours and features. Purchasing zones could be thought of as a sub feature of zones, but it itself still makes up the whole feature of zones.
How many modules is too many?
You can consider modules to be nearly costless or even sometimes negative cost when they help the structure of your code. Because often if you need a feature, it’s only one require away. Splitting that feature into a new module will usually not result in a new require, and even then, at worst you’re just adding one. And even then, many requires is never much of an issue, because you only have to require something once.
Sometimes it’s not uncommon to see modules in well structured codebases which return a simple function.
It all comes down to the “vibe” of the code. It can be better to split things sometimes because it just makes sense, and sometimes it can be better to keep things together because it just makes sense. The one time you can have too many modules is really just when it makes the code make a lot less sense, and that’s usually when you can’t really come up with a good way that makes the idea of that code distinctly separate from something else.
But what about frameworks?
The idea behind a framework initially sounds very appealing, they are something that sounds like it will encourage better code writing. (A frame for your work, if you will ) For example, Knit is a super common and widely used framework, and it is one I personally would recommend if you would like to use a framework.
The problem, however, is that frameworks themselves as a concept are a bit flawed when you look at the software design principles that they use and encourage. The one major flaw with frameworks is that they provide you with the features, rather than you using the features that you need, the coupling between that framework’s features and your code is inverted.
On one hand, this allows frameworks like Knit to make minor encouragements around how you structure your code. On the other hand, it comes with extra cost. Knit doesn’t support type inferences & therefore autocompletes and that means Luau won’t be able to check your code, so you miss bugs. Knit also accidentally makes it easy to make typos in its network code, because you must use self.Server
and Service.Client
, when really you just want self.
. These flaws can likely be attributed mostly to the fact that Knit was new when Luau and types were, and so it’s only natural.
In my experience, frameworks don’t save codebases from architectural issues, and they don’t make the code any prettier. They are not bad, and they are not going to drastically harm your code, but the benefits they provide are usually not worth the cost for any level of programmer. You should not be afraid to use frameworks, but you should be wary of the fact that frameworks as an idea will change the way you write code and change how code acts on the inside for better or for worse.
But don’t frameworks provide useful features? And what can you use to encourage good structure?
If you avoid the use of frameworks and instead resort to using your own structuring with simple module to module requires, the features that frameworks provide naturally themselves become simple modules too, in the form of libraries.
This is exactly what tools like wally
give you in Rojo projects. Wally packages are simply modules, and libraries are simply modules. Wally itself is just a robust way to grab the libraries for your Rojo projects, and ensure that the right versions will always be available to you.
Libraries like Promise
for easy & straightforward scheduling & monadic transformations, TableUtil
for simple, readable table manipulation, Signal
as a performant & lightweight version of BindableEvent
s, Observe
for using the observer pattern, Trove
for object oriented code, and Net
for simple remote based networking all provide you with useful features and tools that won’t restrict how you structure your code while resulting in cleaner, easier to use and write code.